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:
senke 2026-04-26 22:56:31 +02:00
parent 1de016dfeb
commit 083b5718a7
19 changed files with 493 additions and 310 deletions

View file

@ -132,29 +132,17 @@ export const useAuthStore = create<AuthStore>()(
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),

View file

@ -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<RegisterResponse> {
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<RegisterResponse>('/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;

View file

@ -1499,7 +1499,12 @@ export const usePostAuthStreamToken = <TError = InternalHandlersAPIResponse,
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
*/
export type postAuthVerifyEmailResponse200 = {
@ -1521,7 +1526,7 @@ export type postAuthVerifyEmailResponseError = (postAuthVerifyEmailResponse400)
export type postAuthVerifyEmailResponse = (postAuthVerifyEmailResponseSuccess | postAuthVerifyEmailResponseError)
export const getPostAuthVerifyEmailUrl = (params: PostAuthVerifyEmailParams,) => {
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<postAuthVerifyEmailResponse> => {
export const postAuthVerifyEmail = async (params?: PostAuthVerifyEmailParams, options?: RequestInit): Promise<postAuthVerifyEmailResponse> => {
return vezaMutator<postAuthVerifyEmailResponse>(getPostAuthVerifyEmailUrl(params),
{
@ -1551,8 +1556,8 @@ export const postAuthVerifyEmail = async (params: PostAuthVerifyEmailParams, opt
export const getPostAuthVerifyEmailMutationOptions = <TError = InternalHandlersAPIResponse,
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> => {
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> => {
const mutationKey = ['postAuthVerifyEmail'];
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 ?? {};
return postAuthVerifyEmail(params,requestOptions)
@ -1585,11 +1590,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
* @summary Verify Email
*/
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<
Awaited<ReturnType<typeof postAuthVerifyEmail>>,
TError,
{params: PostAuthVerifyEmailParams},
{params?: PostAuthVerifyEmailParams},
TContext
> => {
return useMutation(getPostAuthVerifyEmailMutationOptions(options), queryClient);

View file

@ -8,7 +8,7 @@
export type PostAuthVerifyEmailParams = {
/**
* Verification Token
* Verification Token (deprecated, accepted for backward compat)
*/
token: string;
token?: string;
};

View file

@ -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;
}

View 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/);
});
});

View 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,
);
});
});

View file

@ -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"
}
}
},

View file

@ -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"
}
}
},

View file

@ -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

View file

@ -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

View file

@ -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"`

View file

@ -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) {

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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