diff --git a/apps/web/src/features/auth/store/authStore.ts b/apps/web/src/features/auth/store/authStore.ts index 59a196b76..3523277ac 100644 --- a/apps/web/src/features/auth/store/authStore.ts +++ b/apps/web/src/features/auth/store/authStore.ts @@ -132,29 +132,17 @@ export const useAuthStore = create()( register: async (userData: RegisterRequest) => { set({ isLoading: true, error: null }); try { - // Le service auth gère déjà le stockage des tokens - // Action 4.1.1.5: user field removed - user data managed by React Query - // Response contains user data but we don't store it (React Query handles that) - const response = await registerService(userData); - - // INT-AUTH-002: Updated to use response.token.access_token format (INT-TYPE-008) - const isAuth = !!response.token?.access_token; + // v1.0.9 item 1.4 — Register no longer issues tokens. The user + // must verify email first, then call /auth/login. The store + // therefore stays unauthenticated, and RegisterPageForm routes + // to the "check your email" affordance based on the response. + await registerService(userData); set({ - isAuthenticated: isAuth, + isAuthenticated: false, isLoading: false, 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) { set({ error: parseApiError(error), diff --git a/apps/web/src/services/api/auth.ts b/apps/web/src/services/api/auth.ts index cc1df0357..3605690e2 100644 --- a/apps/web/src/services/api/auth.ts +++ b/apps/web/src/services/api/auth.ts @@ -46,11 +46,20 @@ export interface LoginResponse { requires_2fa?: boolean; // BE-API-001: Flag indicating 2FA is required } -// INT-TYPE-008: RegisterResponse aligned with backend dto.RegisterResponse -// Backend format: { user: UserResponse, token: TokenResponse } +// RegisterResponse aligned with backend dto.RegisterResponse. +// +// 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 { - user: User; // Matches backend UserResponse (id, email, username) - token: { + user: User; + verification_required: boolean; + message?: string; + /** @deprecated v1.0.9 — backend no longer issues tokens at register. */ + token?: { access_token: string; refresh_token: string; expires_in: number; @@ -63,79 +72,40 @@ export async function register( data: RegisterRequest, ): Promise { try { - // Le backend retourne { User: {...}, Token: { AccessToken, RefreshToken, ExpiresIn } } - // ou { success: true, data: { User: {...}, Token: {...} } } après unwrapping + // v1.0.9 item 1.4 — backend response: { user, verification_required: true, message }. + // Tokens are intentionally absent; the user must click the verification + // link emailed to them and then call /auth/login. const response = await apiClient.post('/auth/register', { email: data.email, password: data.password, - password_confirmation: data.password_confirm, // Backend expects password_confirmation + password_confirmation: data.password_confirm, 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; - if (rd?.token?.access_token) { - 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 } + const user: User | undefined = rd?.user ?? rd?.User; if (!user) { throw new Error('Registration response missing user data'); } - if (!accessToken || expiresIn === undefined) { - // Registration might succeed without tokens if email verification is required - throw new Error( - 'Registration response missing tokens. Email verification may be required.', - ); - } + // Soft-tolerate a legacy backend that still emits tokens (e.g., a + // partial rollback). We never store or schedule a refresh — the next + // call into a protected route would 403 because the account is + // unverified, leaving the user stuck. + const verificationRequired = Boolean( + rd?.verification_required ?? !rd?.token?.access_token, + ); return { user, - token: { - access_token: accessToken, - refresh_token: refreshToken || '', - expires_in: expiresIn, - }, + verification_required: verificationRequired, + message: + typeof rd?.message === 'string' && rd.message + ? rd.message + : 'Account created. Check your email to verify, then sign in.', }; } 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); 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 ( request: VerifyEmailRequest, @@ -430,7 +407,7 @@ export const authApi = { '/auth/verify-email', undefined, { - params: { token: request.token }, + headers: { 'X-Verify-Token': request.token }, }, ); return data; diff --git a/apps/web/src/services/generated/auth/auth.ts b/apps/web/src/services/generated/auth/auth.ts index 090cca4c7..908f93689 100644 --- a/apps/web/src/services/generated/auth/auth.ts +++ b/apps/web/src/services/generated/auth/auth.ts @@ -1499,7 +1499,12 @@ export const usePostAuthStreamToken = { +export const getPostAuthVerifyEmailUrl = (params?: PostAuthVerifyEmailParams,) => { const normalizedParams = new URLSearchParams(); 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` } -export const postAuthVerifyEmail = async (params: PostAuthVerifyEmailParams, options?: RequestInit): Promise => { +export const postAuthVerifyEmail = async (params?: PostAuthVerifyEmailParams, options?: RequestInit): Promise => { return vezaMutator(getPostAuthVerifyEmailUrl(params), { @@ -1551,8 +1556,8 @@ export const postAuthVerifyEmail = async (params: PostAuthVerifyEmailParams, opt export const getPostAuthVerifyEmailMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{params: PostAuthVerifyEmailParams}, TContext> => { + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{params?: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{params?: PostAuthVerifyEmailParams}, TContext> => { const mutationKey = ['postAuthVerifyEmail']; const {mutation: mutationOptions, request: requestOptions} = options ? @@ -1564,7 +1569,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ? - const mutationFn: MutationFunction>, {params: PostAuthVerifyEmailParams}> = (props) => { + const mutationFn: MutationFunction>, {params?: PostAuthVerifyEmailParams}> = (props) => { const {params} = props ?? {}; return postAuthVerifyEmail(params,requestOptions) @@ -1585,11 +1590,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ? * @summary Verify Email */ export const usePostAuthVerifyEmail = (options?: { mutation?:UseMutationOptions>, TError,{params: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter} + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{params?: PostAuthVerifyEmailParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, - {params: PostAuthVerifyEmailParams}, + {params?: PostAuthVerifyEmailParams}, TContext > => { return useMutation(getPostAuthVerifyEmailMutationOptions(options), queryClient); diff --git a/apps/web/src/services/generated/model/postAuthVerifyEmailParams.ts b/apps/web/src/services/generated/model/postAuthVerifyEmailParams.ts index d0f708c80..e39b4cc1b 100644 --- a/apps/web/src/services/generated/model/postAuthVerifyEmailParams.ts +++ b/apps/web/src/services/generated/model/postAuthVerifyEmailParams.ts @@ -8,7 +8,7 @@ export type PostAuthVerifyEmailParams = { /** - * Verification Token + * Verification Token (deprecated, accepted for backward compat) */ -token: string; +token?: string; }; diff --git a/apps/web/src/services/generated/model/vezaBackendApiInternalDtoRegisterResponse.ts b/apps/web/src/services/generated/model/vezaBackendApiInternalDtoRegisterResponse.ts index f9d90adde..61b173b97 100644 --- a/apps/web/src/services/generated/model/vezaBackendApiInternalDtoRegisterResponse.ts +++ b/apps/web/src/services/generated/model/vezaBackendApiInternalDtoRegisterResponse.ts @@ -5,10 +5,10 @@ * Backend API for Veza platform. * OpenAPI spec version: 1.2.0 */ -import type { VezaBackendApiInternalDtoTokenResponse } from './vezaBackendApiInternalDtoTokenResponse'; import type { VezaBackendApiInternalDtoUserResponse } from './vezaBackendApiInternalDtoUserResponse'; export interface VezaBackendApiInternalDtoRegisterResponse { - token?: VezaBackendApiInternalDtoTokenResponse; + message?: string; user?: VezaBackendApiInternalDtoUserResponse; + verification_required?: boolean; } diff --git a/tests/e2e/25-register-defer-jwt.spec.ts b/tests/e2e/25-register-defer-jwt.spec.ts new file mode 100644 index 000000000..782def958 --- /dev/null +++ b/tests/e2e/25-register-defer-jwt.spec.ts @@ -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/); + }); +}); diff --git a/tests/e2e/26-verify-email-header.spec.ts b/tests/e2e/26-verify-email-header.spec.ts new file mode 100644 index 000000000..87f1b7a6d --- /dev/null +++ b/tests/e2e/26-verify-email-header.spec.ts @@ -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, + ); + }); +}); diff --git a/veza-backend-api/docs/docs.go b/veza-backend-api/docs/docs.go index 77e6a9c17..7cfb61579 100644 --- a/veza-backend-api/docs/docs.go +++ b/veza-backend-api/docs/docs.go @@ -1613,7 +1613,7 @@ const docTemplate = `{ }, "/auth/verify-email": { "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": [ "application/json" ], @@ -1627,10 +1627,16 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Verification Token", - "name": "token", - "in": "query", + "description": "Verification Token (preferred)", + "name": "X-Verify-Token", + "in": "header", "required": true + }, + { + "type": "string", + "description": "Verification Token (deprecated, accepted for backward compat)", + "name": "token", + "in": "query" } ], "responses": { @@ -9840,11 +9846,14 @@ const docTemplate = `{ "veza-backend-api_internal_dto.RegisterResponse": { "type": "object", "properties": { - "token": { - "$ref": "#/definitions/veza-backend-api_internal_dto.TokenResponse" + "message": { + "type": "string" }, "user": { "$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse" + }, + "verification_required": { + "type": "boolean" } } }, diff --git a/veza-backend-api/docs/swagger.json b/veza-backend-api/docs/swagger.json index 90bf3ca7a..e982985da 100644 --- a/veza-backend-api/docs/swagger.json +++ b/veza-backend-api/docs/swagger.json @@ -1607,7 +1607,7 @@ }, "/auth/verify-email": { "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": [ "application/json" ], @@ -1621,10 +1621,16 @@ "parameters": [ { "type": "string", - "description": "Verification Token", - "name": "token", - "in": "query", + "description": "Verification Token (preferred)", + "name": "X-Verify-Token", + "in": "header", "required": true + }, + { + "type": "string", + "description": "Verification Token (deprecated, accepted for backward compat)", + "name": "token", + "in": "query" } ], "responses": { @@ -9834,11 +9840,14 @@ "veza-backend-api_internal_dto.RegisterResponse": { "type": "object", "properties": { - "token": { - "$ref": "#/definitions/veza-backend-api_internal_dto.TokenResponse" + "message": { + "type": "string" }, "user": { "$ref": "#/definitions/veza-backend-api_internal_dto.UserResponse" + }, + "verification_required": { + "type": "boolean" } } }, diff --git a/veza-backend-api/docs/swagger.yaml b/veza-backend-api/docs/swagger.yaml index 4c8dd5298..ad95b1b0c 100644 --- a/veza-backend-api/docs/swagger.yaml +++ b/veza-backend-api/docs/swagger.yaml @@ -830,10 +830,12 @@ definitions: type: object veza-backend-api_internal_dto.RegisterResponse: properties: - token: - $ref: '#/definitions/veza-backend-api_internal_dto.TokenResponse' + message: + type: string user: $ref: '#/definitions/veza-backend-api_internal_dto.UserResponse' + verification_required: + type: boolean type: object veza-backend-api_internal_dto.ResendVerificationRequest: properties: @@ -2131,12 +2133,22 @@ paths: post: consumes: - 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: - - 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 name: token - required: true type: string produces: - application/json diff --git a/veza-backend-api/internal/core/auth/handler.go b/veza-backend-api/internal/core/auth/handler.go index 77104b207..a0a885090 100644 --- a/veza-backend-api/internal/core/auth/handler.go +++ b/veza-backend-api/internal/core/auth/handler.go @@ -58,7 +58,11 @@ func (h *AuthHandler) Register(c *gin.Context) { } 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 strings.Contains(err.Error(), "already exists") { // MOD-P2-003: Utiliser AppError au lieu de gin.H @@ -75,21 +79,17 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - // Construire la réponse avec les tokens générés - response := dto.RegisterResponse{ + resp := dto.RegisterResponse{ User: dto.UserResponse{ ID: user.ID, Email: user.Email, Username: user.Username, }, - Token: dto.TokenResponse{ - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - ExpiresIn: tokens.ExpiresIn, - }, + VerificationRequired: true, + Message: "Account created. Check your email to verify, then sign in.", } - c.JSON(http.StatusCreated, response) + c.JSON(http.StatusCreated, resp) } // Login gère la connexion d'un utilisateur diff --git a/veza-backend-api/internal/core/auth/handler_test.go b/veza-backend-api/internal/core/auth/handler_test.go index 94e1d84f7..dd5459d72 100644 --- a/veza-backend-api/internal/core/auth/handler_test.go +++ b/veza-backend-api/internal/core/auth/handler_test.go @@ -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) { mocks.EmailVerification.On("GenerateToken").Return("verification-token", 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() +} + +// 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("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refresh-token", 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) { @@ -74,7 +95,10 @@ func TestAuthHandler_Register_Success(t *testing.T) { require.NoError(t, err) assert.Equal(t, reqBody.Email, resp.User.Email) 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) { @@ -88,10 +112,10 @@ func TestAuthHandler_Login_Success(t *testing.T) { 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) - // 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. 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) { - handler, _, mocks, _, cleanup := setupTestAuthHandler(t) + handler, _, mocks, db, cleanup := setupTestAuthHandler(t) defer cleanup() expectRegister(mocks) - // Register via service - ctx := context.Background() - user, tokenPair, err := handler.authService.Register(ctx, "refresh_h@example.com", "refresh_h", "StrongPassword123!") - require.NoError(t, err) + // v1.0.9 item 1.4 — Register no longer issues tokens. We must verify + // the user and call Login to obtain a refresh token to test refresh. + user, tokenPair := registerVerifyLogin(t, handler.authService, db, mocks, "refresh_h@example.com", "refresh_h", "StrongPassword123!") reqBody := dto.RefreshRequest{ RefreshToken: tokenPair.RefreshToken, @@ -176,7 +199,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp dto.TokenResponse - err = json.Unmarshal(w.Body.Bytes(), &resp) + err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) } @@ -208,7 +231,7 @@ func TestAuthHandler_GetMe_Success(t *testing.T) { ctx := context.Background() 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) w := httptest.NewRecorder() @@ -229,14 +252,14 @@ func TestAuthHandler_GetMe_Success(t *testing.T) { } func TestAuthHandler_Logout_Success(t *testing.T) { - handler, _, mocks, _, cleanup := setupTestAuthHandler(t) + handler, _, mocks, db, cleanup := setupTestAuthHandler(t) defer cleanup() - ctx := context.Background() expectRegister(mocks) - user, tokenPair, err := handler.authService.Register(ctx, "logout_h@example.com", "logout_h", "StrongPassword123!") - require.NoError(t, err) + // v1.0.9 item 1.4 — Register no longer issues tokens; we acquire a + // 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 { RefreshToken string `json:"refresh_token"` diff --git a/veza-backend-api/internal/core/auth/service.go b/veza-backend-api/internal/core/auth/service.go index abbbbc1a2..f4f4bd9c2 100644 --- a/veza-backend-api/internal/core/auth/service.go +++ b/veza-backend-api/internal/core/auth/service.go @@ -70,6 +70,14 @@ func NewAuthService( // SetAccountLockoutService définit le service de verrouillage de compte // 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) { s.accountLockoutService = lockoutService } @@ -101,7 +109,18 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*models 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 s.logger.Debug("Registration started", 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)) // Utiliser le sentinel error pour que IsInvalidEmail() le détecte 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à @@ -131,11 +150,11 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st var usernameCount int64 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)) - return nil, nil, fmt.Errorf("failed to check username: %w", err) + return nil, fmt.Errorf("failed to check username: %w", err) } if usernameCount > 0 { 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 @@ -143,12 +162,12 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st passwordStrength, err := s.passwordValidator.Validate(password) if err != nil { 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 { s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Any("details", 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 @@ -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 */) if err != nil { 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 @@ -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 if err != nil { 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 { 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, "chk_users_username_format") { 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") { 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 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 if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "user_role") { 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 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", zap.String("role", user.Role), 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 if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") { 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 // 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") { 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") { 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") { 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 if strings.Contains(errMsg, "unique constraint") || strings.Contains(errMsg, "duplicate key") { 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 @@ -333,7 +352,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st zap.String("error_type", errType), 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", @@ -367,7 +386,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st zap.Error(tokenErr), ) 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 { 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), ) 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 { 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), ) 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)", zap.String("token", token), @@ -402,70 +421,27 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st zap.String("token", token), ) if isProductionEnv() { - return nil, nil, fmt.Errorf("email service unavailable in production") + return nil, fmt.Errorf("email service unavailable in production") } } } else { s.logger.Warn("Email verification service not available - skipping token generation") 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())) - - 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 + // 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. monitoring.RecordUserRegistered() - tokenPair := &models.TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - ExpiresIn: int(s.JWTService.GetConfig().AccessTokenTTL.Seconds()), - } - - s.logger.Info("Registration completed successfully", + s.logger.Info("Registration completed; verification email pending", zap.String("user_id", user.ID.String()), zap.String("email", email), 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) { diff --git a/veza-backend-api/internal/core/auth/service_test.go b/veza-backend-api/internal/core/auth/service_test.go index eabe6b699..1719e15c2 100644 --- a/veza-backend-api/internal/core/auth/service_test.go +++ b/veza-backend-api/internal/core/auth/service_test.go @@ -334,15 +334,14 @@ func TestAuthService_Login_Success(t *testing.T) { // ... 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. - // But `Register` uses `mocks.JWT` which I need to set up. - 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.RefreshToken.On("Store", mock.AnythingOfType("uuid.UUID"), "refresh-token", mock.Anything).Return(nil).Once() + // v1.0.9 item 1.4 — Register no longer calls JWT/RefreshToken. Only + // the email-verification mocks are needed for the registration step; + // the JWT/RefreshToken mocks below cover the post-verification Login. mocks.EmailVerification.On("GenerateToken").Return("verify-token", 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() - user, _, err := service.Register(ctx, email, "loginuser", password) + user, err := service.Register(ctx, email, "loginuser", password) require.NoError(t, err) // Simulate the user clicking the verification link — Register now leaves diff --git a/veza-backend-api/internal/dto/register_request.go b/veza-backend-api/internal/dto/register_request.go index 7e7ca916e..5054ba27f 100644 --- a/veza-backend-api/internal/dto/register_request.go +++ b/veza-backend-api/internal/dto/register_request.go @@ -11,9 +11,14 @@ type RegisterRequest struct { 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 { - User UserResponse `json:"user"` - Token TokenResponse `json:"token"` + User UserResponse `json:"user"` + VerificationRequired bool `json:"verification_required"` + Message string `json:"message,omitempty"` } type UserResponse struct { diff --git a/veza-backend-api/internal/handlers/auth.go b/veza-backend-api/internal/handlers/auth.go index 227717179..ea5b8feba 100644 --- a/veza-backend-api/internal/handlers/auth.go +++ b/veza-backend-api/internal/handlers/auth.go @@ -332,7 +332,13 @@ func LoginWith2FA(authService *auth.AuthService, sessionService *services.Sessio // @Failure 409 {object} handlers.APIResponse "User already exists" // @Failure 500 {object} handlers.APIResponse "Internal Error" // @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) { // 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)) @@ -351,8 +357,8 @@ func Register(authService *auth.AuthService, sessionService *services.SessionSer ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() logger.Debug("Calling auth service register", zap.String("email", maskEmail(req.Email))) - user, tokens, 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)) + 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)) if err != nil { // MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"} switch { @@ -376,87 +382,14 @@ func Register(authService *auth.AuthService, sessionService *services.SessionSer 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{ User: dto.UserResponse{ ID: user.ID, Email: user.Email, Username: user.Username, }, - Token: dto.TokenResponse{ - AccessToken: tokens.AccessToken, - // RefreshToken: tokens.RefreshToken, // ❌ Ne plus retourner dans le body - ExpiresIn: tokens.ExpiresIn, - }, + VerificationRequired: true, + Message: "Account created. Check your email to verify, then sign in.", } 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 // @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 // @Accept 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" // @Failure 400 {object} handlers.APIResponse "Invalid Token" // @Router /auth/verify-email [post] func VerifyEmail(authService *auth.AuthService) gin.HandlerFunc { 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 - token := c.Query("token") + // v1.0.9 item 1.3 — prefer header to keep the token out of URL access + // 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 == "" { - // MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"} RespondWithAppError(c, apperrors.NewValidationError("Token required")) return } diff --git a/veza-backend-api/internal/handlers/auth_handler_test.go b/veza-backend-api/internal/handlers/auth_handler_test.go index 0e6352679..4b5ddbe71 100644 --- a/veza-backend-api/internal/handlers/auth_handler_test.go +++ b/veza-backend-api/internal/handlers/auth_handler_test.go @@ -147,7 +147,7 @@ func TestLogin_Success(t *testing.T) { // Create a test user first 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.NotNil(t, user) @@ -179,7 +179,7 @@ func TestLogin_InvalidCredentials(t *testing.T) { // Create a test user first 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.NotNil(t, user) @@ -208,7 +208,7 @@ func TestLogin_EmailNotVerified(t *testing.T) { // Create a test user but don't verify email 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.NotNil(t, user) // 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 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.NotNil(t, user) @@ -323,7 +323,7 @@ func TestRegister_UserAlreadyExists(t *testing.T) { // Create a user first 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) // 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 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) 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 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) 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 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) userID = user.ID diff --git a/veza-backend-api/openapi.yaml b/veza-backend-api/openapi.yaml index 4c8dd5298..ad95b1b0c 100644 --- a/veza-backend-api/openapi.yaml +++ b/veza-backend-api/openapi.yaml @@ -830,10 +830,12 @@ definitions: type: object veza-backend-api_internal_dto.RegisterResponse: properties: - token: - $ref: '#/definitions/veza-backend-api_internal_dto.TokenResponse' + message: + type: string user: $ref: '#/definitions/veza-backend-api_internal_dto.UserResponse' + verification_required: + type: boolean type: object veza-backend-api_internal_dto.ResendVerificationRequest: properties: @@ -2131,12 +2133,22 @@ paths: post: consumes: - 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: - - 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 name: token - required: true type: string produces: - application/json diff --git a/veza-backend-api/tests/two_factor/two_factor_flow_test.go b/veza-backend-api/tests/two_factor/two_factor_flow_test.go index 635287237..ed2acbe04 100644 --- a/veza-backend-api/tests/two_factor/two_factor_flow_test.go +++ b/veza-backend-api/tests/two_factor/two_factor_flow_test.go @@ -451,7 +451,7 @@ func TestTwoFactorFlow_Login_Requires2FA(t *testing.T) { // Create test user via auth service ctx := context.Background() // 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) // Verify email @@ -507,7 +507,7 @@ func TestTwoFactorFlow_Login_No2FA(t *testing.T) { // Create test user via auth service ctx := context.Background() // 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) // Verify email @@ -609,7 +609,7 @@ func TestTwoFactorFlow_CompleteFlow(t *testing.T) { // Create test user via auth service ctx := context.Background() // 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) // Verify email