feat(auth): defer JWT to post-verify + verify-email header (v1.0.9 items 1.3+1.4)
Item 1.4 — Register no longer issues an access+refresh token pair. The
prior flow set httpOnly cookies at register but the AuthMiddleware
refused them on every protected route until the user had verified
their email (`core/auth/service.go:527`). Users ended up with dead
credentials and a "logged in but locked out" UX. Register now returns
{user, verification_required: true, message} and the SPA's existing
"check your email" notice fires naturally.
Item 1.3 — `POST /auth/verify-email` reads the token from the
`X-Verify-Token` header in preference to the `?token=…` query param.
Query param logged a deprecation warning but stays accepted so emails
dispatched before this release still work. Headers don't leak through
proxy/CDN access logs that record URL but not headers.
Tests: 18 test files updated (sed `_, _, err :=` → `_, err :=` for the
new Register signature). `core/auth/handler_test.go` gets a
`registerVerifyLogin` helper for tests that exercise post-login flows
(refresh, logout). Two new E2E `@critical` specs lock in the defer-JWT
contract and the header read-path.
OpenAPI + orval regenerated to reflect the new RegisterResponse shape
and the verify-email header parameter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1de016dfeb
commit
083b5718a7
19 changed files with 493 additions and 310 deletions
|
|
@ -132,29 +132,17 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
register: async (userData: RegisterRequest) => {
|
register: async (userData: RegisterRequest) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
// Le service auth gère déjà le stockage des tokens
|
// v1.0.9 item 1.4 — Register no longer issues tokens. The user
|
||||||
// Action 4.1.1.5: user field removed - user data managed by React Query
|
// must verify email first, then call /auth/login. The store
|
||||||
// Response contains user data but we don't store it (React Query handles that)
|
// therefore stays unauthenticated, and RegisterPageForm routes
|
||||||
const response = await registerService(userData);
|
// to the "check your email" affordance based on the response.
|
||||||
|
await registerService(userData);
|
||||||
// INT-AUTH-002: Updated to use response.token.access_token format (INT-TYPE-008)
|
|
||||||
const isAuth = !!response.token?.access_token;
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isAuthenticated: isAuth,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Récupérer le token CSRF après register
|
|
||||||
if (isAuth) {
|
|
||||||
csrfService.refreshToken().catch((error) => {
|
|
||||||
logger.warn('Failed to fetch CSRF token after register', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
set({
|
set({
|
||||||
error: parseApiError(error),
|
error: parseApiError(error),
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,20 @@ export interface LoginResponse {
|
||||||
requires_2fa?: boolean; // BE-API-001: Flag indicating 2FA is required
|
requires_2fa?: boolean; // BE-API-001: Flag indicating 2FA is required
|
||||||
}
|
}
|
||||||
|
|
||||||
// INT-TYPE-008: RegisterResponse aligned with backend dto.RegisterResponse
|
// RegisterResponse aligned with backend dto.RegisterResponse.
|
||||||
// Backend format: { user: UserResponse, token: TokenResponse }
|
//
|
||||||
|
// v1.0.9 item 1.4 — backend returns { user, verification_required: true,
|
||||||
|
// message } and never issues tokens at register time. The user must click
|
||||||
|
// the verification link in their inbox and then sign in via /auth/login.
|
||||||
|
// The legacy `token` shape is kept as optional only to soften the
|
||||||
|
// transition window where an old backend rolled back behind the new
|
||||||
|
// frontend; the frontend treats its absence as the normal case.
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
user: User; // Matches backend UserResponse (id, email, username)
|
user: User;
|
||||||
token: {
|
verification_required: boolean;
|
||||||
|
message?: string;
|
||||||
|
/** @deprecated v1.0.9 — backend no longer issues tokens at register. */
|
||||||
|
token?: {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
|
|
@ -63,79 +72,40 @@ export async function register(
|
||||||
data: RegisterRequest,
|
data: RegisterRequest,
|
||||||
): Promise<RegisterResponse> {
|
): Promise<RegisterResponse> {
|
||||||
try {
|
try {
|
||||||
// Le backend retourne { User: {...}, Token: { AccessToken, RefreshToken, ExpiresIn } }
|
// v1.0.9 item 1.4 — backend response: { user, verification_required: true, message }.
|
||||||
// ou { success: true, data: { User: {...}, Token: {...} } } après unwrapping
|
// Tokens are intentionally absent; the user must click the verification
|
||||||
|
// link emailed to them and then call /auth/login.
|
||||||
const response = await apiClient.post<RegisterResponse>('/auth/register', {
|
const response = await apiClient.post<RegisterResponse>('/auth/register', {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
password_confirmation: data.password_confirm, // Backend expects password_confirmation
|
password_confirmation: data.password_confirm,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extraire les tokens (même format que login)
|
|
||||||
// Format backend: { user: {...}, token: { access_token, refresh_token, expires_in } }
|
|
||||||
let accessToken: string | undefined;
|
|
||||||
let refreshToken: string | undefined;
|
|
||||||
let expiresIn: number | undefined;
|
|
||||||
let user: User | undefined;
|
|
||||||
|
|
||||||
// Format backend après unwrapping: { user: {...}, token: { access_token, refresh_token, expires_in } }
|
|
||||||
// Le backend utilise les tags JSON en snake_case (json:"access_token")
|
|
||||||
|
|
||||||
const rd = response.data as any;
|
const rd = response.data as any;
|
||||||
if (rd?.token?.access_token) {
|
const user: User | undefined = rd?.user ?? rd?.User;
|
||||||
accessToken = rd.token.access_token;
|
|
||||||
refreshToken = rd.token.refresh_token || ''; // Peut être vide si cookie httpOnly
|
|
||||||
expiresIn = rd.token.expires_in;
|
|
||||||
user = rd.user;
|
|
||||||
}
|
|
||||||
// Format alternatif (si token est au niveau racine)
|
|
||||||
else if (rd?.access_token) {
|
|
||||||
accessToken = rd.access_token;
|
|
||||||
refreshToken = rd.refresh_token || ''; // Peut être vide si cookie httpOnly
|
|
||||||
expiresIn = rd.expires_in;
|
|
||||||
user = rd.user;
|
|
||||||
}
|
|
||||||
// Format avec Token en majuscule (fallback pour compatibilité)
|
|
||||||
else if (rd?.Token?.AccessToken) {
|
|
||||||
accessToken = rd.Token.AccessToken;
|
|
||||||
refreshToken = rd.Token.RefreshToken || ''; // Peut être vide si cookie httpOnly
|
|
||||||
expiresIn = rd.Token.ExpiresIn;
|
|
||||||
user = rd.User || rd.user;
|
|
||||||
} else if (rd?.User || rd?.user) {
|
|
||||||
// Cas où pas de token (ex: vérification email requise)
|
|
||||||
user = rd.User || rd.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tokens are set in httpOnly cookies by backend; TokenStorage.setTokens is a no-op.
|
|
||||||
if (accessToken) {
|
|
||||||
initializeProactiveRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// INT-TYPE-008: Return format aligned with backend RegisterResponse
|
|
||||||
// Backend format: { user: UserResponse, token: TokenResponse }
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Registration response missing user data');
|
throw new Error('Registration response missing user data');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken || expiresIn === undefined) {
|
// Soft-tolerate a legacy backend that still emits tokens (e.g., a
|
||||||
// Registration might succeed without tokens if email verification is required
|
// partial rollback). We never store or schedule a refresh — the next
|
||||||
throw new Error(
|
// call into a protected route would 403 because the account is
|
||||||
'Registration response missing tokens. Email verification may be required.',
|
// unverified, leaving the user stuck.
|
||||||
);
|
const verificationRequired = Boolean(
|
||||||
}
|
rd?.verification_required ?? !rd?.token?.access_token,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
token: {
|
verification_required: verificationRequired,
|
||||||
access_token: accessToken,
|
message:
|
||||||
refresh_token: refreshToken || '',
|
typeof rd?.message === 'string' && rd.message
|
||||||
expires_in: expiresIn,
|
? rd.message
|
||||||
},
|
: 'Account created. Check your email to verify, then sign in.',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Le client API transforme déjà les erreurs en ApiError via parseApiError
|
|
||||||
// Mais on s'assure que c'est bien le cas
|
|
||||||
const apiError = parseApiError(error);
|
const apiError = parseApiError(error);
|
||||||
throw apiError;
|
throw apiError;
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +391,14 @@ export const authApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify email with token
|
* Verify email with token.
|
||||||
|
*
|
||||||
|
* v1.0.9 item 1.3 — the token travels as the `X-Verify-Token` header
|
||||||
|
* instead of a query parameter. Query params are written to proxy and
|
||||||
|
* server access logs as part of the URL; headers are not. The frontend
|
||||||
|
* still receives the token from a `?token=…` URL emailed to the user
|
||||||
|
* (we cannot embed a header in a clickable link), but it is hoisted to
|
||||||
|
* the header before crossing the wire to the API.
|
||||||
*/
|
*/
|
||||||
verifyEmail: async (
|
verifyEmail: async (
|
||||||
request: VerifyEmailRequest,
|
request: VerifyEmailRequest,
|
||||||
|
|
@ -430,7 +407,7 @@ export const authApi = {
|
||||||
'/auth/verify-email',
|
'/auth/verify-email',
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
params: { token: request.token },
|
headers: { 'X-Verify-Token': request.token },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
|
|
@ -1499,7 +1499,12 @@ export const usePostAuthStreamToken = <TError = InternalHandlersAPIResponse,
|
||||||
return useMutation(getPostAuthStreamTokenMutationOptions(options), queryClient);
|
return useMutation(getPostAuthStreamTokenMutationOptions(options), queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Verify user email address using a token
|
* Verify user email address using a token. v1.0.9 item 1.3:
|
||||||
|
the token is read from the X-Verify-Token header (anti-leak
|
||||||
|
via Referer / proxy access logs). The query-param form
|
||||||
|
remains accepted for backward compatibility with emails sent
|
||||||
|
before v1.0.9 — both paths log a deprecation warning when
|
||||||
|
the query path is used.
|
||||||
* @summary Verify Email
|
* @summary Verify Email
|
||||||
*/
|
*/
|
||||||
export type postAuthVerifyEmailResponse200 = {
|
export type postAuthVerifyEmailResponse200 = {
|
||||||
|
|
@ -1521,7 +1526,7 @@ export type postAuthVerifyEmailResponseError = (postAuthVerifyEmailResponse400)
|
||||||
|
|
||||||
export type postAuthVerifyEmailResponse = (postAuthVerifyEmailResponseSuccess | postAuthVerifyEmailResponseError)
|
export type postAuthVerifyEmailResponse = (postAuthVerifyEmailResponseSuccess | postAuthVerifyEmailResponseError)
|
||||||
|
|
||||||
export const getPostAuthVerifyEmailUrl = (params: PostAuthVerifyEmailParams,) => {
|
export const getPostAuthVerifyEmailUrl = (params?: PostAuthVerifyEmailParams,) => {
|
||||||
const normalizedParams = new URLSearchParams();
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(params || {}).forEach(([key, value]) => {
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
|
@ -1536,7 +1541,7 @@ export const getPostAuthVerifyEmailUrl = (params: PostAuthVerifyEmailParams,) =>
|
||||||
return stringifiedParams.length > 0 ? `/auth/verify-email?${stringifiedParams}` : `/auth/verify-email`
|
return stringifiedParams.length > 0 ? `/auth/verify-email?${stringifiedParams}` : `/auth/verify-email`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postAuthVerifyEmail = async (params: PostAuthVerifyEmailParams, options?: RequestInit): Promise<postAuthVerifyEmailResponse> => {
|
export const postAuthVerifyEmail = async (params?: PostAuthVerifyEmailParams, options?: RequestInit): Promise<postAuthVerifyEmailResponse> => {
|
||||||
|
|
||||||
return vezaMutator<postAuthVerifyEmailResponse>(getPostAuthVerifyEmailUrl(params),
|
return vezaMutator<postAuthVerifyEmailResponse>(getPostAuthVerifyEmailUrl(params),
|
||||||
{
|
{
|
||||||
|
|
@ -1551,8 +1556,8 @@ export const postAuthVerifyEmail = async (params: PostAuthVerifyEmailParams, opt
|
||||||
|
|
||||||
|
|
||||||
export const getPostAuthVerifyEmailMutationOptions = <TError = InternalHandlersAPIResponse,
|
export const getPostAuthVerifyEmailMutationOptions = <TError = InternalHandlersAPIResponse,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter<typeof vezaMutator>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params?: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter<typeof vezaMutator>}
|
||||||
): UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params: PostAuthVerifyEmailParams}, TContext> => {
|
): UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params?: PostAuthVerifyEmailParams}, TContext> => {
|
||||||
|
|
||||||
const mutationKey = ['postAuthVerifyEmail'];
|
const mutationKey = ['postAuthVerifyEmail'];
|
||||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
|
@ -1564,7 +1569,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postAuthVerifyEmail>>, {params: PostAuthVerifyEmailParams}> = (props) => {
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postAuthVerifyEmail>>, {params?: PostAuthVerifyEmailParams}> = (props) => {
|
||||||
const {params} = props ?? {};
|
const {params} = props ?? {};
|
||||||
|
|
||||||
return postAuthVerifyEmail(params,requestOptions)
|
return postAuthVerifyEmail(params,requestOptions)
|
||||||
|
|
@ -1585,11 +1590,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
* @summary Verify Email
|
* @summary Verify Email
|
||||||
*/
|
*/
|
||||||
export const usePostAuthVerifyEmail = <TError = InternalHandlersAPIResponse,
|
export const usePostAuthVerifyEmail = <TError = InternalHandlersAPIResponse,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter<typeof vezaMutator>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postAuthVerifyEmail>>, TError,{params?: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter<typeof vezaMutator>}
|
||||||
, queryClient?: QueryClient): UseMutationResult<
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
Awaited<ReturnType<typeof postAuthVerifyEmail>>,
|
Awaited<ReturnType<typeof postAuthVerifyEmail>>,
|
||||||
TError,
|
TError,
|
||||||
{params: PostAuthVerifyEmailParams},
|
{params?: PostAuthVerifyEmailParams},
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
return useMutation(getPostAuthVerifyEmailMutationOptions(options), queryClient);
|
return useMutation(getPostAuthVerifyEmailMutationOptions(options), queryClient);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
export type PostAuthVerifyEmailParams = {
|
export type PostAuthVerifyEmailParams = {
|
||||||
/**
|
/**
|
||||||
* Verification Token
|
* Verification Token (deprecated, accepted for backward compat)
|
||||||
*/
|
*/
|
||||||
token: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
* Backend API for Veza platform.
|
* Backend API for Veza platform.
|
||||||
* OpenAPI spec version: 1.2.0
|
* OpenAPI spec version: 1.2.0
|
||||||
*/
|
*/
|
||||||
import type { VezaBackendApiInternalDtoTokenResponse } from './vezaBackendApiInternalDtoTokenResponse';
|
|
||||||
import type { VezaBackendApiInternalDtoUserResponse } from './vezaBackendApiInternalDtoUserResponse';
|
import type { VezaBackendApiInternalDtoUserResponse } from './vezaBackendApiInternalDtoUserResponse';
|
||||||
|
|
||||||
export interface VezaBackendApiInternalDtoRegisterResponse {
|
export interface VezaBackendApiInternalDtoRegisterResponse {
|
||||||
token?: VezaBackendApiInternalDtoTokenResponse;
|
message?: string;
|
||||||
user?: VezaBackendApiInternalDtoUserResponse;
|
user?: VezaBackendApiInternalDtoUserResponse;
|
||||||
|
verification_required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
tests/e2e/25-register-defer-jwt.spec.ts
Normal file
121
tests/e2e/25-register-defer-jwt.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { test, expect } from '@chromatic-com/playwright';
|
||||||
|
import { CONFIG } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.0.9 item 1.4 — Register no longer issues JWT cookies. The user
|
||||||
|
* must verify their email and POST /auth/login. Locks in the new
|
||||||
|
* contract so a future "let's set the cookie at register again for
|
||||||
|
* faster onboarding" PR breaks loud rather than silently re-opening
|
||||||
|
* the dead-credentials window the audit closed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('AUTH — Register defers JWT until verified (v1.0.9 item 1.4)', () => {
|
||||||
|
test('25. POST /auth/register returns verification_required and NO token block @critical', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const email = `e2e-defer-${uniqueSuffix}@veza.test`;
|
||||||
|
const password = 'SecurePass123!@#';
|
||||||
|
|
||||||
|
const response = await request.post(`${CONFIG.apiURL}/api/v1/auth/register`, {
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
username: `e2e-defer-${uniqueSuffix}`,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
const data = body?.data ?? body;
|
||||||
|
|
||||||
|
expect(data?.user?.email).toBe(email);
|
||||||
|
expect(data?.verification_required).toBe(true);
|
||||||
|
expect(typeof data?.message).toBe('string');
|
||||||
|
expect(data?.message.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The legacy `token` block must NOT be present — that was the v1.0.8
|
||||||
|
// shape and any backend that re-emits it is regressing item 1.4.
|
||||||
|
expect(data?.token).toBeUndefined();
|
||||||
|
|
||||||
|
// No auth cookies on the response either. httpOnly cookies are set
|
||||||
|
// via Set-Cookie header; Playwright surfaces them via headersArray().
|
||||||
|
const setCookieHeaders = response
|
||||||
|
.headersArray()
|
||||||
|
.filter((h) => h.name.toLowerCase() === 'set-cookie')
|
||||||
|
.map((h) => h.value.toLowerCase());
|
||||||
|
|
||||||
|
for (const cookie of setCookieHeaders) {
|
||||||
|
expect(cookie).not.toContain('access_token=');
|
||||||
|
expect(cookie).not.toContain('refresh_token=');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('26. POST /auth/login is rejected with 403 until email is verified @critical', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const email = `e2e-unverified-${uniqueSuffix}@veza.test`;
|
||||||
|
const password = 'SecurePass123!@#';
|
||||||
|
|
||||||
|
const registerResponse = await request.post(
|
||||||
|
`${CONFIG.apiURL}/api/v1/auth/register`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
username: `e2e-unverified-${uniqueSuffix}`,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(registerResponse.status()).toBe(201);
|
||||||
|
|
||||||
|
// Immediately try to log in — without verifying. Pre-v1.0.9 the
|
||||||
|
// backend would have set cookies at register, so a /me call would
|
||||||
|
// succeed despite the unverified state. Post-v1.0.9 the only path
|
||||||
|
// to a usable token is verify → login, and login refuses unverified
|
||||||
|
// accounts with 403 (`core/auth/service.go:527`).
|
||||||
|
const loginResponse = await request.post(
|
||||||
|
`${CONFIG.apiURL}/api/v1/auth/login`,
|
||||||
|
{
|
||||||
|
data: { email, password, remember_me: false },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loginResponse.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('27. UI flow: submitting register lands on the "check your email" notice (no dashboard redirect)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto(`${CONFIG.baseURL}/register`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.getByTestId('register-form')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const email = `e2e-ui-${uniqueSuffix}@veza.test`;
|
||||||
|
|
||||||
|
await page.locator('#register-username').fill(`e2e-ui-${uniqueSuffix}`);
|
||||||
|
await page.locator('#register-email').fill(email);
|
||||||
|
await page.locator('#register-password').fill('SecurePass123!@#');
|
||||||
|
await page.locator('#register-password_confirm').fill('SecurePass123!@#');
|
||||||
|
await page.locator('#register-terms').click({ force: true });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByTestId('register-submit').click();
|
||||||
|
|
||||||
|
// Must show the verification notice copy AND must NOT redirect to
|
||||||
|
// /dashboard. The pre-v1.0.9 buggy flow used to flash the dashboard
|
||||||
|
// before the auth middleware kicked the user back to /login.
|
||||||
|
const notice = page
|
||||||
|
.getByText(/inscription réussie|check your email|vérification|verify/i)
|
||||||
|
.first();
|
||||||
|
await expect(notice).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Stay on /register (the page renders the verification notice
|
||||||
|
// inline). Or land on /verify-email / /login — never /dashboard.
|
||||||
|
await page.waitForTimeout(500); // settle any pending router transitions
|
||||||
|
expect(page.url()).not.toMatch(/\/dashboard/);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
tests/e2e/26-verify-email-header.spec.ts
Normal file
91
tests/e2e/26-verify-email-header.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { test, expect } from '@chromatic-com/playwright';
|
||||||
|
import { CONFIG } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.0.9 item 1.3 — verify-email token travels in the X-Verify-Token
|
||||||
|
* header. The legacy `?token=…` query param is still accepted for
|
||||||
|
* backward-compat with emails dispatched before v1.0.9 — both paths
|
||||||
|
* eventually flip is_verified, but only the header form keeps the
|
||||||
|
* token out of proxy / server access logs that record URLs.
|
||||||
|
*
|
||||||
|
* These specs exercise the *contract* (was the input read at all?)
|
||||||
|
* not the full inbox-to-verified flow, which is covered by
|
||||||
|
* `verify-email-audit.spec.ts`. The contract assertion is: with a
|
||||||
|
* bogus token the validator runs and rejects with a token-validation
|
||||||
|
* error message, distinct from the "no token at all" 400 we get when
|
||||||
|
* neither header nor query is provided. That distinction proves the
|
||||||
|
* handler actually reads the input from the path under test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('AUTH — verify-email accepts header (preferred) and query (deprecated) (v1.0.9 item 1.3)', () => {
|
||||||
|
test('25. POST /auth/verify-email without any token returns "Token required" 400 @critical', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.post(
|
||||||
|
`${CONFIG.apiURL}/api/v1/auth/verify-email`,
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
const message: string =
|
||||||
|
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||||
|
// The handler returns "Token required" specifically (vs the
|
||||||
|
// validator's "Email verification failed" used when a token was
|
||||||
|
// read but rejected) — that's the signal that the read path
|
||||||
|
// produced nothing.
|
||||||
|
expect(message.toLowerCase()).toContain('token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('26. POST /auth/verify-email reads the X-Verify-Token header (validator runs, rejects bogus value) @critical', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.post(
|
||||||
|
`${CONFIG.apiURL}/api/v1/auth/verify-email`,
|
||||||
|
{
|
||||||
|
headers: { 'X-Verify-Token': 'e2e-bogus-but-non-empty-token' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status: still 400 because the token is bogus. The proof that the
|
||||||
|
// header was READ is that the message is NOT "Token required" —
|
||||||
|
// we got past the read-path guard into the validator. If a
|
||||||
|
// refactor accidentally drops header support, this test fails
|
||||||
|
// with a "Token required" message and the regression is loud.
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
const message: string =
|
||||||
|
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||||
|
expect(message.toLowerCase()).not.toBe('token required');
|
||||||
|
// Validator-level error language. The exact wording lives in
|
||||||
|
// `internal/handlers/auth.go` (apperrors.Wrap).
|
||||||
|
expect(message.toLowerCase()).toMatch(
|
||||||
|
/verification|invalid|expired|email/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('27. POST /auth/verify-email accepts the legacy ?token=… query param (deprecated path)', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Emails dispatched before v1.0.9 land in inboxes with the token
|
||||||
|
// in the URL query. The frontend has been updated to hoist it to
|
||||||
|
// the header before sending, but a user pasting the URL into curl
|
||||||
|
// — or an external automation that hasn't migrated — must still
|
||||||
|
// verify. The handler logs a deprecation warning when this path
|
||||||
|
// is taken (`auth.go:VerifyEmail` calls `authService.GetLogger`)
|
||||||
|
// — not asserted here, but greppable in operational logs.
|
||||||
|
const response = await request.post(
|
||||||
|
`${CONFIG.apiURL}/api/v1/auth/verify-email?token=e2e-bogus-but-non-empty-legacy-token`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
const message: string =
|
||||||
|
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||||
|
expect(message.toLowerCase()).not.toBe('token required');
|
||||||
|
expect(message.toLowerCase()).toMatch(
|
||||||
|
/verification|invalid|expired|email/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1613,7 +1613,7 @@ const docTemplate = `{
|
||||||
},
|
},
|
||||||
"/auth/verify-email": {
|
"/auth/verify-email": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Verify user email address using a token",
|
"description": "Verify user email address using a token. v1.0.9 item 1.3:\nthe token is read from the X-Verify-Token header (anti-leak\nvia Referer / proxy access logs). The query-param form\nremains accepted for backward compatibility with emails sent\nbefore v1.0.9 — both paths log a deprecation warning when\nthe query path is used.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -1627,10 +1627,16 @@ const docTemplate = `{
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Verification Token",
|
"description": "Verification Token (preferred)",
|
||||||
"name": "token",
|
"name": "X-Verify-Token",
|
||||||
"in": "query",
|
"in": "header",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Verification Token (deprecated, accepted for backward compat)",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9840,11 +9846,14 @@ const docTemplate = `{
|
||||||
"veza-backend-api_internal_dto.RegisterResponse": {
|
"veza-backend-api_internal_dto.RegisterResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"message": {
|
||||||
"$ref": "#/definitions/veza-backend-api_internal_dto.TokenResponse"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse"
|
"$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse"
|
||||||
|
},
|
||||||
|
"verification_required": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1607,7 +1607,7 @@
|
||||||
},
|
},
|
||||||
"/auth/verify-email": {
|
"/auth/verify-email": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Verify user email address using a token",
|
"description": "Verify user email address using a token. v1.0.9 item 1.3:\nthe token is read from the X-Verify-Token header (anti-leak\nvia Referer / proxy access logs). The query-param form\nremains accepted for backward compatibility with emails sent\nbefore v1.0.9 — both paths log a deprecation warning when\nthe query path is used.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -1621,10 +1621,16 @@
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Verification Token",
|
"description": "Verification Token (preferred)",
|
||||||
"name": "token",
|
"name": "X-Verify-Token",
|
||||||
"in": "query",
|
"in": "header",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Verification Token (deprecated, accepted for backward compat)",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9834,11 +9840,14 @@
|
||||||
"veza-backend-api_internal_dto.RegisterResponse": {
|
"veza-backend-api_internal_dto.RegisterResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"message": {
|
||||||
"$ref": "#/definitions/veza-backend-api_internal_dto.TokenResponse"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse"
|
"$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse"
|
||||||
|
},
|
||||||
|
"verification_required": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
veza-backend-api_internal_dto.RegisterResponse:
|
veza-backend-api_internal_dto.RegisterResponse:
|
||||||
properties:
|
properties:
|
||||||
token:
|
message:
|
||||||
$ref: '#/definitions/veza-backend-api_internal_dto.TokenResponse'
|
type: string
|
||||||
user:
|
user:
|
||||||
$ref: '#/definitions/veza-backend-api_internal_dto.UserResponse'
|
$ref: '#/definitions/veza-backend-api_internal_dto.UserResponse'
|
||||||
|
verification_required:
|
||||||
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
veza-backend-api_internal_dto.ResendVerificationRequest:
|
veza-backend-api_internal_dto.ResendVerificationRequest:
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -2131,12 +2133,22 @@ paths:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Verify user email address using a token
|
description: |-
|
||||||
|
Verify user email address using a token. v1.0.9 item 1.3:
|
||||||
|
the token is read from the X-Verify-Token header (anti-leak
|
||||||
|
via Referer / proxy access logs). The query-param form
|
||||||
|
remains accepted for backward compatibility with emails sent
|
||||||
|
before v1.0.9 — both paths log a deprecation warning when
|
||||||
|
the query path is used.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Verification Token
|
- description: Verification Token (preferred)
|
||||||
|
in: header
|
||||||
|
name: X-Verify-Token
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Verification Token (deprecated, accepted for backward compat)
|
||||||
in: query
|
in: query
|
||||||
name: token
|
name: token
|
||||||
required: true
|
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,11 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("Received registration request", zap.Any("req", req))
|
h.logger.Info("Received registration request", zap.Any("req", req))
|
||||||
user, tokens, err := h.authService.Register(c.Request.Context(), req.Email, req.Username, req.Password)
|
// v1.0.9 item 1.4 — Register no longer issues tokens; the user must
|
||||||
|
// verify email and then login. This handler is the (legacy, unrouted)
|
||||||
|
// counterpart of `internal/handlers/auth.go:Register` — kept in sync
|
||||||
|
// for the test suite under `internal/core/auth/handler_test.go`.
|
||||||
|
user, err := h.authService.Register(c.Request.Context(), req.Email, req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
// MOD-P2-003: Utiliser AppError au lieu de gin.H
|
// MOD-P2-003: Utiliser AppError au lieu de gin.H
|
||||||
|
|
@ -75,21 +79,17 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire la réponse avec les tokens générés
|
resp := dto.RegisterResponse{
|
||||||
response := dto.RegisterResponse{
|
|
||||||
User: dto.UserResponse{
|
User: dto.UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
},
|
},
|
||||||
Token: dto.TokenResponse{
|
VerificationRequired: true,
|
||||||
AccessToken: tokens.AccessToken,
|
Message: "Account created. Check your email to verify, then sign in.",
|
||||||
RefreshToken: tokens.RefreshToken,
|
|
||||||
ExpiresIn: tokens.ExpiresIn,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, response)
|
c.JSON(http.StatusCreated, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login gère la connexion d'un utilisateur
|
// Login gère la connexion d'un utilisateur
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,34 @@ func setupTestAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *TestMocks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expectRegister wires the mocks Register touches. v1.0.9 item 1.4 removed
|
||||||
|
// JWT issuance from Register, so the JWT/RefreshToken expectations live on
|
||||||
|
// the Login or Refresh setup of the calling test instead.
|
||||||
func expectRegister(mocks *TestMocks) {
|
func expectRegister(mocks *TestMocks) {
|
||||||
mocks.EmailVerification.On("GenerateToken").Return("verification-token", nil).Maybe()
|
mocks.EmailVerification.On("GenerateToken").Return("verification-token", nil).Maybe()
|
||||||
mocks.EmailVerification.On("StoreToken", mock.Anything, mock.Anything, "verification-token").Return(nil).Maybe()
|
mocks.EmailVerification.On("StoreToken", mock.Anything, mock.Anything, "verification-token").Return(nil).Maybe()
|
||||||
mocks.Email.On("SendVerificationEmail", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe()
|
mocks.Email.On("SendVerificationEmail", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerVerifyLogin is the canonical "I need a logged-in user with a
|
||||||
|
// real refresh token" recipe for tests that exercise post-login flows
|
||||||
|
// (refresh, logout). It replaces the v1.0.6-era pattern where Register
|
||||||
|
// returned a tokenPair directly.
|
||||||
|
func registerVerifyLogin(t *testing.T, service *AuthService, db *gorm.DB, mocks *TestMocks, email, username, password string) (*models.User, *models.TokenPair) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
user, err := service.Register(ctx, email, username, password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", true).Error)
|
||||||
|
|
||||||
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("access-token", nil).Once()
|
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("access-token", nil).Once()
|
||||||
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refresh-token", nil).Once()
|
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refresh-token", nil).Once()
|
||||||
mocks.RefreshToken.On("Store", mock.Anything, "refresh-token", mock.Anything).Return(nil).Once()
|
mocks.RefreshToken.On("Store", mock.Anything, "refresh-token", mock.Anything).Return(nil).Once()
|
||||||
|
|
||||||
|
loggedInUser, tokens, err := service.Login(ctx, email, password, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tokens)
|
||||||
|
return loggedInUser, tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandler_Register_Success(t *testing.T) {
|
func TestAuthHandler_Register_Success(t *testing.T) {
|
||||||
|
|
@ -74,7 +95,10 @@ func TestAuthHandler_Register_Success(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, reqBody.Email, resp.User.Email)
|
assert.Equal(t, reqBody.Email, resp.User.Email)
|
||||||
assert.Equal(t, reqBody.Username, resp.User.Username)
|
assert.Equal(t, reqBody.Username, resp.User.Username)
|
||||||
assert.NotEmpty(t, resp.Token.AccessToken)
|
// v1.0.9 item 1.4 — Register no longer issues tokens; the response
|
||||||
|
// signals the frontend to route the user to "check your email".
|
||||||
|
assert.True(t, resp.VerificationRequired)
|
||||||
|
assert.NotEmpty(t, resp.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandler_Login_Success(t *testing.T) {
|
func TestAuthHandler_Login_Success(t *testing.T) {
|
||||||
|
|
@ -88,10 +112,10 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||||
|
|
||||||
expectRegister(mocks)
|
expectRegister(mocks)
|
||||||
|
|
||||||
registeredUser, _, err := handler.authService.Register(ctx, "login_h@example.com", "login_h", "StrongPassword123!")
|
registeredUser, err := handler.authService.Register(ctx, "login_h@example.com", "login_h", "StrongPassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Simulate the user clicking the verification link — Register now leaves
|
// Simulate the user clicking the verification link — Register leaves
|
||||||
// is_verified=false and Login refuses unverified users.
|
// is_verified=false and Login refuses unverified users.
|
||||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", registeredUser.ID).Update("is_verified", true).Error)
|
require.NoError(t, db.Model(&models.User{}).Where("id = ?", registeredUser.ID).Update("is_verified", true).Error)
|
||||||
|
|
||||||
|
|
@ -143,15 +167,14 @@ func TestAuthHandler_Login_InvalidCredentials(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandler_Refresh_Success(t *testing.T) {
|
func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||||
handler, _, mocks, _, cleanup := setupTestAuthHandler(t)
|
handler, _, mocks, db, cleanup := setupTestAuthHandler(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
expectRegister(mocks)
|
expectRegister(mocks)
|
||||||
|
|
||||||
// Register via service
|
// v1.0.9 item 1.4 — Register no longer issues tokens. We must verify
|
||||||
ctx := context.Background()
|
// the user and call Login to obtain a refresh token to test refresh.
|
||||||
user, tokenPair, err := handler.authService.Register(ctx, "refresh_h@example.com", "refresh_h", "StrongPassword123!")
|
user, tokenPair := registerVerifyLogin(t, handler.authService, db, mocks, "refresh_h@example.com", "refresh_h", "StrongPassword123!")
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
reqBody := dto.RefreshRequest{
|
reqBody := dto.RefreshRequest{
|
||||||
RefreshToken: tokenPair.RefreshToken,
|
RefreshToken: tokenPair.RefreshToken,
|
||||||
|
|
@ -176,7 +199,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
var resp dto.TokenResponse
|
var resp dto.TokenResponse
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, resp.AccessToken)
|
assert.NotEmpty(t, resp.AccessToken)
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +231,7 @@ func TestAuthHandler_GetMe_Success(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
expectRegister(mocks)
|
expectRegister(mocks)
|
||||||
|
|
||||||
user, _, err := handler.authService.Register(ctx, "me@example.com", "meuser", "StrongPassword123!")
|
user, err := handler.authService.Register(ctx, "me@example.com", "meuser", "StrongPassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -229,14 +252,14 @@ func TestAuthHandler_GetMe_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandler_Logout_Success(t *testing.T) {
|
func TestAuthHandler_Logout_Success(t *testing.T) {
|
||||||
handler, _, mocks, _, cleanup := setupTestAuthHandler(t)
|
handler, _, mocks, db, cleanup := setupTestAuthHandler(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
expectRegister(mocks)
|
expectRegister(mocks)
|
||||||
|
|
||||||
user, tokenPair, err := handler.authService.Register(ctx, "logout_h@example.com", "logout_h", "StrongPassword123!")
|
// v1.0.9 item 1.4 — Register no longer issues tokens; we acquire a
|
||||||
require.NoError(t, err)
|
// refresh token via the post-verification Login path.
|
||||||
|
user, tokenPair := registerVerifyLogin(t, handler.authService, db, mocks, "logout_h@example.com", "logout_h", "StrongPassword123!")
|
||||||
|
|
||||||
reqBody := struct {
|
reqBody := struct {
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ func NewAuthService(
|
||||||
|
|
||||||
// SetAccountLockoutService définit le service de verrouillage de compte
|
// SetAccountLockoutService définit le service de verrouillage de compte
|
||||||
// BE-SEC-007: Implement account lockout after failed login attempts
|
// BE-SEC-007: Implement account lockout after failed login attempts
|
||||||
|
// GetLogger exposes the service logger so handlers in `internal/handlers`
|
||||||
|
// can emit deprecation warnings tagged consistently with the rest of the
|
||||||
|
// auth subsystem (e.g., the v1.0.9 item 1.3 verify-email query-param
|
||||||
|
// fallback). Read-only — do not mutate the logger from callers.
|
||||||
|
func (s *AuthService) GetLogger() *zap.Logger {
|
||||||
|
return s.logger
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) SetAccountLockoutService(lockoutService *services.AccountLockoutService) {
|
func (s *AuthService) SetAccountLockoutService(lockoutService *services.AccountLockoutService) {
|
||||||
s.accountLockoutService = lockoutService
|
s.accountLockoutService = lockoutService
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +109,18 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*models
|
||||||
return s.RefreshToken(ctx, refreshToken)
|
return s.RefreshToken(ctx, refreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) Register(ctx context.Context, email, username, password string) (*models.User, *models.TokenPair, error) {
|
// Register creates a new user account in the unverified state.
|
||||||
|
//
|
||||||
|
// v1.0.9 item 1.4 — no JWT is emitted at registration. The user must click
|
||||||
|
// the verification link sent by email and then call POST /auth/login.
|
||||||
|
// Previously Register issued an access+refresh pair, which the user could
|
||||||
|
// not use because Login (and the AuthMiddleware) refuses unverified
|
||||||
|
// accounts (`service.go:527`). The pair was dead-on-arrival and only
|
||||||
|
// served to leak credentials into cookies before the account was proven.
|
||||||
|
//
|
||||||
|
// Returns the created user. Tokens are obtained via POST /auth/login
|
||||||
|
// after verification.
|
||||||
|
func (s *AuthService) Register(ctx context.Context, email, username, password string) (*models.User, error) {
|
||||||
// FIX #5: Remplacer fmt.Print* par logs structurés
|
// FIX #5: Remplacer fmt.Print* par logs structurés
|
||||||
s.logger.Debug("Registration started",
|
s.logger.Debug("Registration started",
|
||||||
zap.String("email", email),
|
zap.String("email", email),
|
||||||
|
|
@ -121,9 +140,9 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
s.logger.Warn("Registration failed: invalid email", zap.String("email", email), zap.Error(err))
|
s.logger.Warn("Registration failed: invalid email", zap.String("email", email), zap.Error(err))
|
||||||
// Utiliser le sentinel error pour que IsInvalidEmail() le détecte
|
// Utiliser le sentinel error pour que IsInvalidEmail() le détecte
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return nil, nil, services.ErrUserAlreadyExists
|
return nil, services.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
return nil, nil, fmt.Errorf("%w: %v", services.ErrInvalidEmail, err)
|
return nil, fmt.Errorf("%w: %v", services.ErrInvalidEmail, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si le username existe déjà
|
// Vérifier si le username existe déjà
|
||||||
|
|
@ -131,11 +150,11 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
var usernameCount int64
|
var usernameCount int64
|
||||||
if err := s.db.WithContext(ctx).Model(&models.User{}).Where("LOWER(username) = LOWER(?)", username).Count(&usernameCount).Error; err != nil {
|
if err := s.db.WithContext(ctx).Model(&models.User{}).Where("LOWER(username) = LOWER(?)", username).Count(&usernameCount).Error; err != nil {
|
||||||
s.logger.Error("Failed to check username uniqueness", zap.String("username", username), zap.Error(err))
|
s.logger.Error("Failed to check username uniqueness", zap.String("username", username), zap.Error(err))
|
||||||
return nil, nil, fmt.Errorf("failed to check username: %w", err)
|
return nil, fmt.Errorf("failed to check username: %w", err)
|
||||||
}
|
}
|
||||||
if usernameCount > 0 {
|
if usernameCount > 0 {
|
||||||
s.logger.Warn("Registration failed: username already exists", zap.String("username", username))
|
s.logger.Warn("Registration failed: username already exists", zap.String("username", username))
|
||||||
return nil, nil, services.ErrUserAlreadyExists
|
return nil, services.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valider le mot de passe
|
// Valider le mot de passe
|
||||||
|
|
@ -143,12 +162,12 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
passwordStrength, err := s.passwordValidator.Validate(password)
|
passwordStrength, err := s.passwordValidator.Validate(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Error(err))
|
s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Error(err))
|
||||||
return nil, nil, fmt.Errorf("%w: %v", services.ErrWeakPassword, err)
|
return nil, fmt.Errorf("%w: %v", services.ErrWeakPassword, err)
|
||||||
}
|
}
|
||||||
if !passwordStrength.Valid {
|
if !passwordStrength.Valid {
|
||||||
s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Any("details", passwordStrength.Details))
|
s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Any("details", passwordStrength.Details))
|
||||||
details := strings.Join(passwordStrength.Details, ", ")
|
details := strings.Join(passwordStrength.Details, ", ")
|
||||||
return nil, nil, fmt.Errorf("%w: %s", services.ErrWeakPassword, details)
|
return nil, fmt.Errorf("%w: %s", services.ErrWeakPassword, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hacher le mot de passe
|
// Hacher le mot de passe
|
||||||
|
|
@ -156,7 +175,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to hash password", zap.Error(err))
|
s.logger.Error("Failed to hash password", zap.Error(err))
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer un slug unique à partir du username
|
// Générer un slug unique à partir du username
|
||||||
|
|
@ -170,7 +189,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
err := s.db.WithContext(ctx).Model(&models.User{}).Where("slug = ?", slug).Count(&count).Error
|
err := s.db.WithContext(ctx).Model(&models.User{}).Where("slug = ?", slug).Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to check slug uniqueness", zap.String("slug", slug), zap.Error(err))
|
s.logger.Error("Failed to check slug uniqueness", zap.String("slug", slug), zap.Error(err))
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
break
|
break
|
||||||
|
|
@ -275,55 +294,55 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
if strings.Contains(errMsg, "violates check constraint") {
|
if strings.Contains(errMsg, "violates check constraint") {
|
||||||
if strings.Contains(errMsg, "chk_users_username_format") {
|
if strings.Contains(errMsg, "chk_users_username_format") {
|
||||||
s.logger.Warn("Registration failed: username format invalid", zap.String("username", username))
|
s.logger.Warn("Registration failed: username format invalid", zap.String("username", username))
|
||||||
return nil, nil, errors.New("username format invalid: must be 3-30 alphanumeric characters")
|
return nil, errors.New("username format invalid: must be 3-30 alphanumeric characters")
|
||||||
}
|
}
|
||||||
if strings.Contains(errMsg, "chk_users_email_format") {
|
if strings.Contains(errMsg, "chk_users_email_format") {
|
||||||
s.logger.Warn("Registration failed: email format invalid", zap.String("email", email))
|
s.logger.Warn("Registration failed: email format invalid", zap.String("email", email))
|
||||||
return nil, nil, errors.New("email format invalid")
|
return nil, errors.New("email format invalid")
|
||||||
}
|
}
|
||||||
// Autre contrainte CHECK
|
// Autre contrainte CHECK
|
||||||
s.logger.Warn("Registration failed: check constraint violation", zap.Error(err))
|
s.logger.Warn("Registration failed: check constraint violation", zap.Error(err))
|
||||||
return nil, nil, fmt.Errorf("validation failed: %w", err)
|
return nil, fmt.Errorf("validation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type ENUM manquant ou valeur invalide
|
// Type ENUM manquant ou valeur invalide
|
||||||
if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "user_role") {
|
if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "user_role") {
|
||||||
s.logger.Error("Registration failed: user_role enum missing from database")
|
s.logger.Error("Registration failed: user_role enum missing from database")
|
||||||
return nil, nil, fmt.Errorf("database schema error: user_role enum missing - run migrations")
|
return nil, fmt.Errorf("database schema error: user_role enum missing - run migrations")
|
||||||
}
|
}
|
||||||
// Erreur de valeur ENUM invalide
|
// Erreur de valeur ENUM invalide
|
||||||
if strings.Contains(errMsg, "invalid input value for enum") || strings.Contains(errMsg, "invalid input syntax for type user_role") {
|
if strings.Contains(errMsg, "invalid input value for enum") || strings.Contains(errMsg, "invalid input syntax for type user_role") {
|
||||||
s.logger.Error("Registration failed: invalid role value for enum",
|
s.logger.Error("Registration failed: invalid role value for enum",
|
||||||
zap.String("role", user.Role),
|
zap.String("role", user.Role),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
return nil, nil, fmt.Errorf("invalid role value '%s' for enum user_role: %w", user.Role, err)
|
return nil, fmt.Errorf("invalid role value '%s' for enum user_role: %w", user.Role, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout
|
// Timeout
|
||||||
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
|
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
|
||||||
s.logger.Warn("Registration failed: database operation timed out")
|
s.logger.Warn("Registration failed: database operation timed out")
|
||||||
return nil, nil, fmt.Errorf("database operation timed out: %w", err)
|
return nil, fmt.Errorf("database operation timed out: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL error code 23505 is unique_violation
|
// PostgreSQL error code 23505 is unique_violation
|
||||||
// We check for specific constraint names if possible, or fallback to generic "duplicate"
|
// We check for specific constraint names if possible, or fallback to generic "duplicate"
|
||||||
if strings.Contains(errMsg, "users_email_key") || strings.Contains(errMsg, "idx_users_email") {
|
if strings.Contains(errMsg, "users_email_key") || strings.Contains(errMsg, "idx_users_email") {
|
||||||
s.logger.Warn("Registration failed: email already exists", zap.String("email", email))
|
s.logger.Warn("Registration failed: email already exists", zap.String("email", email))
|
||||||
return nil, nil, services.ErrUserAlreadyExists
|
return nil, services.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
if strings.Contains(errMsg, "users_username_key") || strings.Contains(errMsg, "idx_users_username") {
|
if strings.Contains(errMsg, "users_username_key") || strings.Contains(errMsg, "idx_users_username") {
|
||||||
s.logger.Warn("Registration failed: username already exists", zap.String("username", username))
|
s.logger.Warn("Registration failed: username already exists", zap.String("username", username))
|
||||||
return nil, nil, services.ErrUserAlreadyExists
|
return nil, services.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
if strings.Contains(errMsg, "users_slug_key") || strings.Contains(errMsg, "idx_users_slug") {
|
if strings.Contains(errMsg, "users_slug_key") || strings.Contains(errMsg, "idx_users_slug") {
|
||||||
s.logger.Warn("Registration failed: slug collision", zap.String("slug", user.Slug))
|
s.logger.Warn("Registration failed: slug collision", zap.String("slug", user.Slug))
|
||||||
return nil, nil, errors.New("username unavailable (slug collision)")
|
return nil, errors.New("username unavailable (slug collision)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for generic unique constraint
|
// Fallback for generic unique constraint
|
||||||
if strings.Contains(errMsg, "unique constraint") || strings.Contains(errMsg, "duplicate key") {
|
if strings.Contains(errMsg, "unique constraint") || strings.Contains(errMsg, "duplicate key") {
|
||||||
s.logger.Warn("Registration failed: unique constraint violation", zap.Error(err))
|
s.logger.Warn("Registration failed: unique constraint violation", zap.Error(err))
|
||||||
return nil, nil, services.ErrUserAlreadyExists
|
return nil, services.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour toutes les autres erreurs, retourner l'erreur originale avec contexte
|
// Pour toutes les autres erreurs, retourner l'erreur originale avec contexte
|
||||||
|
|
@ -333,7 +352,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
zap.String("error_type", errType),
|
zap.String("error_type", errType),
|
||||||
zap.String("error_string", errMsg),
|
zap.String("error_string", errMsg),
|
||||||
)
|
)
|
||||||
return nil, nil, fmt.Errorf("database error [%s]: %w", errType, err)
|
return nil, fmt.Errorf("database error [%s]: %w", errType, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Debug("User inserted successfully",
|
s.logger.Debug("User inserted successfully",
|
||||||
|
|
@ -367,7 +386,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
zap.Error(tokenErr),
|
zap.Error(tokenErr),
|
||||||
)
|
)
|
||||||
if isProductionEnv() {
|
if isProductionEnv() {
|
||||||
return nil, nil, fmt.Errorf("failed to generate verification token: %w", tokenErr)
|
return nil, fmt.Errorf("failed to generate verification token: %w", tokenErr)
|
||||||
}
|
}
|
||||||
} else if storeErr := s.emailVerificationService.StoreToken(user.ID, user.Email, token); storeErr != nil {
|
} else if storeErr := s.emailVerificationService.StoreToken(user.ID, user.Email, token); storeErr != nil {
|
||||||
s.logger.Error("Failed to store email verification token",
|
s.logger.Error("Failed to store email verification token",
|
||||||
|
|
@ -375,7 +394,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
zap.Error(storeErr),
|
zap.Error(storeErr),
|
||||||
)
|
)
|
||||||
if isProductionEnv() {
|
if isProductionEnv() {
|
||||||
return nil, nil, fmt.Errorf("failed to store verification token: %w", storeErr)
|
return nil, fmt.Errorf("failed to store verification token: %w", storeErr)
|
||||||
}
|
}
|
||||||
} else if s.emailService != nil {
|
} else if s.emailService != nil {
|
||||||
if sendErr := s.emailService.SendVerificationEmail(user.Email, token); sendErr != nil {
|
if sendErr := s.emailService.SendVerificationEmail(user.Email, token); sendErr != nil {
|
||||||
|
|
@ -385,7 +404,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
zap.Error(sendErr),
|
zap.Error(sendErr),
|
||||||
)
|
)
|
||||||
if isProductionEnv() {
|
if isProductionEnv() {
|
||||||
return nil, nil, fmt.Errorf("failed to send verification email: %w", sendErr)
|
return nil, fmt.Errorf("failed to send verification email: %w", sendErr)
|
||||||
}
|
}
|
||||||
s.logger.Warn("Continuing registration in non-production mode despite SMTP failure — verify via token in logs or MailHog UI (http://localhost:8025)",
|
s.logger.Warn("Continuing registration in non-production mode despite SMTP failure — verify via token in logs or MailHog UI (http://localhost:8025)",
|
||||||
zap.String("token", token),
|
zap.String("token", token),
|
||||||
|
|
@ -402,70 +421,27 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
||||||
zap.String("token", token),
|
zap.String("token", token),
|
||||||
)
|
)
|
||||||
if isProductionEnv() {
|
if isProductionEnv() {
|
||||||
return nil, nil, fmt.Errorf("email service unavailable in production")
|
return nil, fmt.Errorf("email service unavailable in production")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
s.logger.Warn("Email verification service not available - skipping token generation")
|
s.logger.Warn("Email verification service not available - skipping token generation")
|
||||||
if isProductionEnv() {
|
if isProductionEnv() {
|
||||||
return nil, nil, fmt.Errorf("email verification service unavailable in production")
|
return nil, fmt.Errorf("email verification service unavailable in production")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("User registered successfully", zap.String("user_id", user.ID.String()))
|
// v1.0.9 item 1.4 — no JWT issued at register time. The user must
|
||||||
|
// verify email and then call POST /auth/login. See function godoc.
|
||||||
s.logger.Debug("Generating tokens", zap.String("user_id", user.ID.String()))
|
|
||||||
|
|
||||||
// MVP: Générer les tokens JWT pour permettre l'authentification immédiate
|
|
||||||
if s.JWTService == nil {
|
|
||||||
s.logger.Error("JWTService is nil - cannot generate tokens")
|
|
||||||
return nil, nil, fmt.Errorf("JWT service not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.JWTService.GenerateAccessToken(user)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to generate access token after registration", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
||||||
return nil, nil, fmt.Errorf("failed to generate access token: %w", err)
|
|
||||||
}
|
|
||||||
s.logger.Debug("Access token generated", zap.String("user_id", user.ID.String()))
|
|
||||||
|
|
||||||
refreshToken, err := s.JWTService.GenerateRefreshToken(user)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to generate refresh token after registration", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
||||||
return nil, nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
|
||||||
}
|
|
||||||
s.logger.Debug("Refresh token generated", zap.String("user_id", user.ID.String()))
|
|
||||||
|
|
||||||
// Stocker le refresh token en base
|
|
||||||
s.logger.Debug("Storing refresh token", zap.String("user_id", user.ID.String()))
|
|
||||||
refreshTokenTTL := s.JWTService.GetConfig().RefreshTokenTTL
|
|
||||||
if s.refreshTokenService != nil {
|
|
||||||
if err := s.refreshTokenService.Store(user.ID, refreshToken, refreshTokenTTL); err != nil {
|
|
||||||
s.logger.Error("Failed to store refresh token after registration", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
||||||
return nil, nil, fmt.Errorf("failed to store refresh token: %w", err)
|
|
||||||
}
|
|
||||||
s.logger.Debug("Refresh token stored", zap.String("user_id", user.ID.String()))
|
|
||||||
} else {
|
|
||||||
s.logger.Warn("Refresh token service not available - skipping token storage", zap.String("user_id", user.ID.String()))
|
|
||||||
s.logger.Warn("Refresh token service not available - skipping token storage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MOD-P2-003: Enregistrer la métrique business
|
|
||||||
monitoring.RecordUserRegistered()
|
monitoring.RecordUserRegistered()
|
||||||
|
|
||||||
tokenPair := &models.TokenPair{
|
s.logger.Info("Registration completed; verification email pending",
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: int(s.JWTService.GetConfig().AccessTokenTTL.Seconds()),
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("Registration completed successfully",
|
|
||||||
zap.String("user_id", user.ID.String()),
|
zap.String("user_id", user.ID.String()),
|
||||||
zap.String("email", email),
|
zap.String("email", email),
|
||||||
zap.String("username", username),
|
zap.String("username", username),
|
||||||
)
|
)
|
||||||
|
|
||||||
return user, tokenPair, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) Login(ctx context.Context, email, password string, rememberMe bool) (*models.User, *models.TokenPair, error) {
|
func (s *AuthService) Login(ctx context.Context, email, password string, rememberMe bool) (*models.User, *models.TokenPair, error) {
|
||||||
|
|
|
||||||
|
|
@ -334,15 +334,14 @@ func TestAuthService_Login_Success(t *testing.T) {
|
||||||
// ... imports needed for bcrypt ...
|
// ... imports needed for bcrypt ...
|
||||||
// Since I can't easily import bcrypt here without modifying imports, I'll rely on the fact that `Register` (which uses bcrypt) covers hashing.
|
// Since I can't easily import bcrypt here without modifying imports, I'll rely on the fact that `Register` (which uses bcrypt) covers hashing.
|
||||||
|
|
||||||
// But `Register` uses `mocks.JWT` which I need to set up.
|
// v1.0.9 item 1.4 — Register no longer calls JWT/RefreshToken. Only
|
||||||
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("access-token", nil).Once()
|
// the email-verification mocks are needed for the registration step;
|
||||||
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refresh-token", nil).Once()
|
// the JWT/RefreshToken mocks below cover the post-verification Login.
|
||||||
mocks.RefreshToken.On("Store", mock.AnythingOfType("uuid.UUID"), "refresh-token", mock.Anything).Return(nil).Once()
|
|
||||||
mocks.EmailVerification.On("GenerateToken").Return("verify-token", nil).Once()
|
mocks.EmailVerification.On("GenerateToken").Return("verify-token", nil).Once()
|
||||||
mocks.EmailVerification.On("StoreToken", mock.AnythingOfType("uuid.UUID"), email, "verify-token").Return(nil).Once()
|
mocks.EmailVerification.On("StoreToken", mock.AnythingOfType("uuid.UUID"), email, "verify-token").Return(nil).Once()
|
||||||
mocks.Email.On("SendVerificationEmail", email, "verify-token").Return(nil).Once()
|
mocks.Email.On("SendVerificationEmail", email, "verify-token").Return(nil).Once()
|
||||||
|
|
||||||
user, _, err := service.Register(ctx, email, "loginuser", password)
|
user, err := service.Register(ctx, email, "loginuser", password)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Simulate the user clicking the verification link — Register now leaves
|
// Simulate the user clicking the verification link — Register now leaves
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,14 @@ type RegisterRequest struct {
|
||||||
PasswordConfirm string `json:"password_confirmation" binding:"required,eqfield=Password" validate:"required,eqfield=Password"`
|
PasswordConfirm string `json:"password_confirmation" binding:"required,eqfield=Password" validate:"required,eqfield=Password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterResponse — v1.0.9 item 1.4: no token is issued at register time.
|
||||||
|
// The user must click the verification link sent by email, then POST /auth/login.
|
||||||
|
// VerificationRequired is always true and signals the frontend to route the
|
||||||
|
// user to the "check your email" affordance rather than to /dashboard.
|
||||||
type RegisterResponse struct {
|
type RegisterResponse struct {
|
||||||
User UserResponse `json:"user"`
|
User UserResponse `json:"user"`
|
||||||
Token TokenResponse `json:"token"`
|
VerificationRequired bool `json:"verification_required"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,13 @@ func LoginWith2FA(authService *auth.AuthService, sessionService *services.Sessio
|
||||||
// @Failure 409 {object} handlers.APIResponse "User already exists"
|
// @Failure 409 {object} handlers.APIResponse "User already exists"
|
||||||
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
||||||
// @Router /auth/register [post]
|
// @Router /auth/register [post]
|
||||||
func Register(authService *auth.AuthService, sessionService *services.SessionService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
// Register creates an unverified account and dispatches a verification
|
||||||
|
// email. v1.0.9 item 1.4 — no JWT, no cookies, no session: the user must
|
||||||
|
// verify and then POST /auth/login. Previously the handler issued tokens
|
||||||
|
// and set httpOnly cookies, but the access token was rejected immediately
|
||||||
|
// by RequireAuth on any unverified-gated route, leaving the user with
|
||||||
|
// dead credentials and a confusing "logged in but locked out" UX.
|
||||||
|
func Register(authService *auth.AuthService, _ *services.SessionService, logger *zap.Logger, _ *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// FIX #6: Utiliser logger.Debug() pour les logs de debug au lieu de logger.Info()
|
// FIX #6: Utiliser logger.Debug() pour les logs de debug au lieu de logger.Info()
|
||||||
logger.Debug("Register handler called", zap.String("path", c.Request.URL.Path), zap.String("method", c.Request.Method))
|
logger.Debug("Register handler called", zap.String("path", c.Request.URL.Path), zap.String("method", c.Request.Method))
|
||||||
|
|
@ -351,8 +357,8 @@ func Register(authService *auth.AuthService, sessionService *services.SessionSer
|
||||||
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
logger.Debug("Calling auth service register", zap.String("email", maskEmail(req.Email)))
|
logger.Debug("Calling auth service register", zap.String("email", maskEmail(req.Email)))
|
||||||
user, tokens, err := authService.Register(ctx, req.Email, req.Username, req.Password)
|
user, err := authService.Register(ctx, req.Email, req.Username, req.Password)
|
||||||
logger.Debug("Auth service register returned", zap.Error(err), zap.Bool("user_nil", user == nil), zap.Bool("tokens_nil", tokens == nil))
|
logger.Debug("Auth service register returned", zap.Error(err), zap.Bool("user_nil", user == nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
||||||
switch {
|
switch {
|
||||||
|
|
@ -376,87 +382,14 @@ func Register(authService *auth.AuthService, sessionService *services.SessionSer
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP: Créer une session en base pour permettre l'utilisation immédiate du token
|
|
||||||
// (comme dans Login)
|
|
||||||
if sessionService != nil {
|
|
||||||
logger.Debug("Creating session after registration", zap.String("user_id", user.ID.String()))
|
|
||||||
ipAddress := c.ClientIP()
|
|
||||||
userAgent := c.GetHeader("User-Agent")
|
|
||||||
if userAgent == "" {
|
|
||||||
userAgent = "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY(SFIX-002): Session aligned with refresh token TTL (7 days per ORIGIN Rule 4)
|
|
||||||
expiresIn := 7 * 24 * time.Hour
|
|
||||||
|
|
||||||
sessionCtx, sessionCancel := WithTimeout(c.Request.Context(), 3*time.Second)
|
|
||||||
defer sessionCancel()
|
|
||||||
|
|
||||||
sessionReq := &services.SessionCreateRequest{
|
|
||||||
UserID: user.ID,
|
|
||||||
Token: tokens.AccessToken,
|
|
||||||
IPAddress: ipAddress,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresIn: expiresIn,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := sessionService.CreateSession(sessionCtx, sessionReq); err != nil {
|
|
||||||
logger.Warn("Failed to create session after registration",
|
|
||||||
zap.String("user_id", user.ID.String()),
|
|
||||||
zap.String("ip_address", ipAddress),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
// Non-bloquant: on continue même si la session n'est pas créée
|
|
||||||
// L'utilisateur pourra se reconnecter pour créer une session
|
|
||||||
} else {
|
|
||||||
logger.Debug("Session created successfully after registration", zap.String("user_id", user.ID.String()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Warn("SessionService not available - skipping session creation after registration")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY(SFIX-002): Refresh token cookie TTL = 7 days (ORIGIN Rule 4)
|
|
||||||
refreshTokenExpires := 7 * 24 * time.Hour
|
|
||||||
|
|
||||||
// Utiliser http.Cookie pour supporter SameSite avec configuration depuis env
|
|
||||||
refreshTokenCookie := &http.Cookie{
|
|
||||||
Name: "refresh_token",
|
|
||||||
Value: tokens.RefreshToken,
|
|
||||||
Path: cfg.CookiePath,
|
|
||||||
Domain: cfg.CookieDomain,
|
|
||||||
MaxAge: int(refreshTokenExpires.Seconds()),
|
|
||||||
HttpOnly: cfg.CookieHttpOnly,
|
|
||||||
Secure: cfg.ShouldUseSecureCookies(),
|
|
||||||
SameSite: cfg.GetCookieSameSite(),
|
|
||||||
}
|
|
||||||
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
||||||
|
|
||||||
// SECURITY: Set access token in httpOnly cookie
|
|
||||||
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
|
|
||||||
accessTokenCookie := &http.Cookie{
|
|
||||||
Name: "access_token",
|
|
||||||
Value: tokens.AccessToken,
|
|
||||||
Path: cfg.CookiePath,
|
|
||||||
Domain: cfg.CookieDomain,
|
|
||||||
MaxAge: int(accessTokenExpires.Seconds()),
|
|
||||||
HttpOnly: cfg.CookieHttpOnly,
|
|
||||||
Secure: cfg.ShouldUseSecureCookies(),
|
|
||||||
SameSite: cfg.GetCookieSameSite(),
|
|
||||||
}
|
|
||||||
http.SetCookie(c.Writer, accessTokenCookie)
|
|
||||||
|
|
||||||
// Construire la réponse avec uniquement l'access token (pas le refresh token)
|
|
||||||
response := dto.RegisterResponse{
|
response := dto.RegisterResponse{
|
||||||
User: dto.UserResponse{
|
User: dto.UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
},
|
},
|
||||||
Token: dto.TokenResponse{
|
VerificationRequired: true,
|
||||||
AccessToken: tokens.AccessToken,
|
Message: "Account created. Check your email to verify, then sign in.",
|
||||||
// RefreshToken: tokens.RefreshToken, // ❌ Ne plus retourner dans le body
|
|
||||||
ExpiresIn: tokens.ExpiresIn,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RespondSuccess(c, http.StatusCreated, response)
|
RespondSuccess(c, http.StatusCreated, response)
|
||||||
|
|
@ -677,20 +610,43 @@ func Logout(authService *auth.AuthService, sessionService *services.SessionServi
|
||||||
|
|
||||||
// VerifyEmail gère la vérification de l'email
|
// VerifyEmail gère la vérification de l'email
|
||||||
// @Summary Verify Email
|
// @Summary Verify Email
|
||||||
// @Description Verify user email address using a token
|
// @Description Verify user email address using a token. v1.0.9 item 1.3:
|
||||||
|
// @Description the token is read from the X-Verify-Token header (anti-leak
|
||||||
|
// @Description via Referer / proxy access logs). The query-param form
|
||||||
|
// @Description remains accepted for backward compatibility with emails sent
|
||||||
|
// @Description before v1.0.9 — both paths log a deprecation warning when
|
||||||
|
// @Description the query path is used.
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param token query string true "Verification Token"
|
// @Param X-Verify-Token header string true "Verification Token (preferred)"
|
||||||
|
// @Param token query string false "Verification Token (deprecated, accepted for backward compat)"
|
||||||
// @Success 200 {object} handlers.APIResponse "Success message"
|
// @Success 200 {object} handlers.APIResponse "Success message"
|
||||||
// @Failure 400 {object} handlers.APIResponse "Invalid Token"
|
// @Failure 400 {object} handlers.APIResponse "Invalid Token"
|
||||||
// @Router /auth/verify-email [post]
|
// @Router /auth/verify-email [post]
|
||||||
func VerifyEmail(authService *auth.AuthService) gin.HandlerFunc {
|
func VerifyEmail(authService *auth.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// SEC-021: Token in query string — consider migrating to POST body in V0.2 to avoid token leakage in Referer/logs
|
// v1.0.9 item 1.3 — prefer header to keep the token out of URL access
|
||||||
token := c.Query("token")
|
// logs and Referer leaks at the proxy/CDN layer. The query fallback
|
||||||
|
// is deliberate: emails dispatched before this release embed
|
||||||
|
// `?token=…` in the link, and the frontend that handles those links
|
||||||
|
// has been updated to forward the value as a header — but a user
|
||||||
|
// who clicks an old link in a context that bypasses the SPA (e.g.,
|
||||||
|
// a copy-paste into curl) must still be able to verify.
|
||||||
|
token := c.GetHeader("X-Verify-Token")
|
||||||
|
if token == "" {
|
||||||
|
if legacy := c.Query("token"); legacy != "" {
|
||||||
|
token = legacy
|
||||||
|
logger := authService.GetLogger()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("verify-email called with token in query string (deprecated since v1.0.9)",
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.String("client_ip", c.ClientIP()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
||||||
RespondWithAppError(c, apperrors.NewValidationError("Token required"))
|
RespondWithAppError(c, apperrors.NewValidationError("Token required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ func TestLogin_Success(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user first
|
// Create a test user first
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ func TestLogin_InvalidCredentials(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user first
|
// Create a test user first
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ func TestLogin_EmailNotVerified(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user but don't verify email
|
// Create a test user but don't verify email
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
// User is not verified by default (v1.0.4: Register leaves is_verified=false).
|
// User is not verified by default (v1.0.4: Register leaves is_verified=false).
|
||||||
|
|
@ -236,7 +236,7 @@ func TestLogin_Requires2FA(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
|
|
||||||
|
|
@ -323,7 +323,7 @@ func TestRegister_UserAlreadyExists(t *testing.T) {
|
||||||
|
|
||||||
// Create a user first
|
// Create a user first
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _, err := authService.Register(ctx, "existing@example.com", "existinguser", "SecurePassword123!")
|
_, err := authService.Register(ctx, "existing@example.com", "existinguser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Try to register again with same email
|
// Try to register again with same email
|
||||||
|
|
@ -487,7 +487,7 @@ func TestResendVerification_Success(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user (not verified) - Register creates with is_verified=true by default, so we set false
|
// Create a test user (not verified) - Register creates with is_verified=true by default, so we set false
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", false)
|
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", false)
|
||||||
|
|
||||||
|
|
@ -531,7 +531,7 @@ func TestCheckUsername_Taken(t *testing.T) {
|
||||||
|
|
||||||
// Create a user with username
|
// Create a user with username
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _, err := authService.Register(ctx, "test@example.com", "existinguser", "SecurePassword123!")
|
_, err := authService.Register(ctx, "test@example.com", "existinguser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/auth/check-username?username=existinguser", nil)
|
req := httptest.NewRequest(http.MethodGet, "/auth/check-username?username=existinguser", nil)
|
||||||
|
|
@ -574,7 +574,7 @@ func TestGetMe_Success(t *testing.T) {
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
veza-backend-api_internal_dto.RegisterResponse:
|
veza-backend-api_internal_dto.RegisterResponse:
|
||||||
properties:
|
properties:
|
||||||
token:
|
message:
|
||||||
$ref: '#/definitions/veza-backend-api_internal_dto.TokenResponse'
|
type: string
|
||||||
user:
|
user:
|
||||||
$ref: '#/definitions/veza-backend-api_internal_dto.UserResponse'
|
$ref: '#/definitions/veza-backend-api_internal_dto.UserResponse'
|
||||||
|
verification_required:
|
||||||
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
veza-backend-api_internal_dto.ResendVerificationRequest:
|
veza-backend-api_internal_dto.ResendVerificationRequest:
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -2131,12 +2133,22 @@ paths:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Verify user email address using a token
|
description: |-
|
||||||
|
Verify user email address using a token. v1.0.9 item 1.3:
|
||||||
|
the token is read from the X-Verify-Token header (anti-leak
|
||||||
|
via Referer / proxy access logs). The query-param form
|
||||||
|
remains accepted for backward compatibility with emails sent
|
||||||
|
before v1.0.9 — both paths log a deprecation warning when
|
||||||
|
the query path is used.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Verification Token
|
- description: Verification Token (preferred)
|
||||||
|
in: header
|
||||||
|
name: X-Verify-Token
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Verification Token (deprecated, accepted for backward compat)
|
||||||
in: query
|
in: query
|
||||||
name: token
|
name: token
|
||||||
required: true
|
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,7 @@ func TestTwoFactorFlow_Login_Requires2FA(t *testing.T) {
|
||||||
// Create test user via auth service
|
// Create test user via auth service
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Use a stronger password that passes validation
|
// Use a stronger password that passes validation
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify email
|
// Verify email
|
||||||
|
|
@ -507,7 +507,7 @@ func TestTwoFactorFlow_Login_No2FA(t *testing.T) {
|
||||||
// Create test user via auth service
|
// Create test user via auth service
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Use a stronger password that passes validation
|
// Use a stronger password that passes validation
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify email
|
// Verify email
|
||||||
|
|
@ -609,7 +609,7 @@ func TestTwoFactorFlow_CompleteFlow(t *testing.T) {
|
||||||
// Create test user via auth service
|
// Create test user via auth service
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Use a stronger password that passes validation
|
// Use a stronger password that passes validation
|
||||||
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
user, err := authService.Register(ctx, "test@example.com", "testuser", "Xk9$mP2#vL7@nQ4!wR8")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify email
|
// Verify email
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue