feat(auth): defer JWT to post-verify + verify-email header (v1.0.9 items 1.3+1.4)
Item 1.4 — Register no longer issues an access+refresh token pair. The
prior flow set httpOnly cookies at register but the AuthMiddleware
refused them on every protected route until the user had verified
their email (`core/auth/service.go:527`). Users ended up with dead
credentials and a "logged in but locked out" UX. Register now returns
{user, verification_required: true, message} and the SPA's existing
"check your email" notice fires naturally.
Item 1.3 — `POST /auth/verify-email` reads the token from the
`X-Verify-Token` header in preference to the `?token=…` query param.
Query param logged a deprecation warning but stays accepted so emails
dispatched before this release still work. Headers don't leak through
proxy/CDN access logs that record URL but not headers.
Tests: 18 test files updated (sed `_, _, err :=` → `_, err :=` for the
new Register signature). `core/auth/handler_test.go` gets a
`registerVerifyLogin` helper for tests that exercise post-login flows
(refresh, logout). Two new E2E `@critical` specs lock in the defer-JWT
contract and the header read-path.
OpenAPI + orval regenerated to reflect the new RegisterResponse shape
and the verify-email header parameter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1de016dfeb
commit
083b5718a7
19 changed files with 493 additions and 310 deletions
|
|
@ -132,29 +132,17 @@ export const useAuthStore = create<AuthStore>()(
|
|||
register: async (userData: RegisterRequest) => {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
export type PostAuthVerifyEmailParams = {
|
||||
/**
|
||||
* Verification Token
|
||||
* Verification Token (deprecated, accepted for backward compat)
|
||||
*/
|
||||
token: string;
|
||||
token?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
* Backend API for Veza platform.
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
121
tests/e2e/25-register-defer-jwt.spec.ts
Normal file
121
tests/e2e/25-register-defer-jwt.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { test, expect } from '@chromatic-com/playwright';
|
||||
import { CONFIG } from './helpers';
|
||||
|
||||
/**
|
||||
* v1.0.9 item 1.4 — Register no longer issues JWT cookies. The user
|
||||
* must verify their email and POST /auth/login. Locks in the new
|
||||
* contract so a future "let's set the cookie at register again for
|
||||
* faster onboarding" PR breaks loud rather than silently re-opening
|
||||
* the dead-credentials window the audit closed.
|
||||
*/
|
||||
|
||||
test.describe('AUTH — Register defers JWT until verified (v1.0.9 item 1.4)', () => {
|
||||
test('25. POST /auth/register returns verification_required and NO token block @critical', async ({
|
||||
request,
|
||||
}) => {
|
||||
const uniqueSuffix = Date.now();
|
||||
const email = `e2e-defer-${uniqueSuffix}@veza.test`;
|
||||
const password = 'SecurePass123!@#';
|
||||
|
||||
const response = await request.post(`${CONFIG.apiURL}/api/v1/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
username: `e2e-defer-${uniqueSuffix}`,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
const data = body?.data ?? body;
|
||||
|
||||
expect(data?.user?.email).toBe(email);
|
||||
expect(data?.verification_required).toBe(true);
|
||||
expect(typeof data?.message).toBe('string');
|
||||
expect(data?.message.length).toBeGreaterThan(0);
|
||||
|
||||
// The legacy `token` block must NOT be present — that was the v1.0.8
|
||||
// shape and any backend that re-emits it is regressing item 1.4.
|
||||
expect(data?.token).toBeUndefined();
|
||||
|
||||
// No auth cookies on the response either. httpOnly cookies are set
|
||||
// via Set-Cookie header; Playwright surfaces them via headersArray().
|
||||
const setCookieHeaders = response
|
||||
.headersArray()
|
||||
.filter((h) => h.name.toLowerCase() === 'set-cookie')
|
||||
.map((h) => h.value.toLowerCase());
|
||||
|
||||
for (const cookie of setCookieHeaders) {
|
||||
expect(cookie).not.toContain('access_token=');
|
||||
expect(cookie).not.toContain('refresh_token=');
|
||||
}
|
||||
});
|
||||
|
||||
test('26. POST /auth/login is rejected with 403 until email is verified @critical', async ({
|
||||
request,
|
||||
}) => {
|
||||
const uniqueSuffix = Date.now();
|
||||
const email = `e2e-unverified-${uniqueSuffix}@veza.test`;
|
||||
const password = 'SecurePass123!@#';
|
||||
|
||||
const registerResponse = await request.post(
|
||||
`${CONFIG.apiURL}/api/v1/auth/register`,
|
||||
{
|
||||
data: {
|
||||
email,
|
||||
username: `e2e-unverified-${uniqueSuffix}`,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(registerResponse.status()).toBe(201);
|
||||
|
||||
// Immediately try to log in — without verifying. Pre-v1.0.9 the
|
||||
// backend would have set cookies at register, so a /me call would
|
||||
// succeed despite the unverified state. Post-v1.0.9 the only path
|
||||
// to a usable token is verify → login, and login refuses unverified
|
||||
// accounts with 403 (`core/auth/service.go:527`).
|
||||
const loginResponse = await request.post(
|
||||
`${CONFIG.apiURL}/api/v1/auth/login`,
|
||||
{
|
||||
data: { email, password, remember_me: false },
|
||||
},
|
||||
);
|
||||
|
||||
expect(loginResponse.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('27. UI flow: submitting register lands on the "check your email" notice (no dashboard redirect)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto(`${CONFIG.baseURL}/register`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByTestId('register-form')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const uniqueSuffix = Date.now();
|
||||
const email = `e2e-ui-${uniqueSuffix}@veza.test`;
|
||||
|
||||
await page.locator('#register-username').fill(`e2e-ui-${uniqueSuffix}`);
|
||||
await page.locator('#register-email').fill(email);
|
||||
await page.locator('#register-password').fill('SecurePass123!@#');
|
||||
await page.locator('#register-password_confirm').fill('SecurePass123!@#');
|
||||
await page.locator('#register-terms').click({ force: true });
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByTestId('register-submit').click();
|
||||
|
||||
// Must show the verification notice copy AND must NOT redirect to
|
||||
// /dashboard. The pre-v1.0.9 buggy flow used to flash the dashboard
|
||||
// before the auth middleware kicked the user back to /login.
|
||||
const notice = page
|
||||
.getByText(/inscription réussie|check your email|vérification|verify/i)
|
||||
.first();
|
||||
await expect(notice).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Stay on /register (the page renders the verification notice
|
||||
// inline). Or land on /verify-email / /login — never /dashboard.
|
||||
await page.waitForTimeout(500); // settle any pending router transitions
|
||||
expect(page.url()).not.toMatch(/\/dashboard/);
|
||||
});
|
||||
});
|
||||
91
tests/e2e/26-verify-email-header.spec.ts
Normal file
91
tests/e2e/26-verify-email-header.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { test, expect } from '@chromatic-com/playwright';
|
||||
import { CONFIG } from './helpers';
|
||||
|
||||
/**
|
||||
* v1.0.9 item 1.3 — verify-email token travels in the X-Verify-Token
|
||||
* header. The legacy `?token=…` query param is still accepted for
|
||||
* backward-compat with emails dispatched before v1.0.9 — both paths
|
||||
* eventually flip is_verified, but only the header form keeps the
|
||||
* token out of proxy / server access logs that record URLs.
|
||||
*
|
||||
* These specs exercise the *contract* (was the input read at all?)
|
||||
* not the full inbox-to-verified flow, which is covered by
|
||||
* `verify-email-audit.spec.ts`. The contract assertion is: with a
|
||||
* bogus token the validator runs and rejects with a token-validation
|
||||
* error message, distinct from the "no token at all" 400 we get when
|
||||
* neither header nor query is provided. That distinction proves the
|
||||
* handler actually reads the input from the path under test.
|
||||
*/
|
||||
|
||||
test.describe('AUTH — verify-email accepts header (preferred) and query (deprecated) (v1.0.9 item 1.3)', () => {
|
||||
test('25. POST /auth/verify-email without any token returns "Token required" 400 @critical', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post(
|
||||
`${CONFIG.apiURL}/api/v1/auth/verify-email`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
const message: string =
|
||||
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||
// The handler returns "Token required" specifically (vs the
|
||||
// validator's "Email verification failed" used when a token was
|
||||
// read but rejected) — that's the signal that the read path
|
||||
// produced nothing.
|
||||
expect(message.toLowerCase()).toContain('token');
|
||||
});
|
||||
|
||||
test('26. POST /auth/verify-email reads the X-Verify-Token header (validator runs, rejects bogus value) @critical', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post(
|
||||
`${CONFIG.apiURL}/api/v1/auth/verify-email`,
|
||||
{
|
||||
headers: { 'X-Verify-Token': 'e2e-bogus-but-non-empty-token' },
|
||||
},
|
||||
);
|
||||
|
||||
// Status: still 400 because the token is bogus. The proof that the
|
||||
// header was READ is that the message is NOT "Token required" —
|
||||
// we got past the read-path guard into the validator. If a
|
||||
// refactor accidentally drops header support, this test fails
|
||||
// with a "Token required" message and the regression is loud.
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
const message: string =
|
||||
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||
expect(message.toLowerCase()).not.toBe('token required');
|
||||
// Validator-level error language. The exact wording lives in
|
||||
// `internal/handlers/auth.go` (apperrors.Wrap).
|
||||
expect(message.toLowerCase()).toMatch(
|
||||
/verification|invalid|expired|email/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('27. POST /auth/verify-email accepts the legacy ?token=… query param (deprecated path)', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Emails dispatched before v1.0.9 land in inboxes with the token
|
||||
// in the URL query. The frontend has been updated to hoist it to
|
||||
// the header before sending, but a user pasting the URL into curl
|
||||
// — or an external automation that hasn't migrated — must still
|
||||
// verify. The handler logs a deprecation warning when this path
|
||||
// is taken (`auth.go:VerifyEmail` calls `authService.GetLogger`)
|
||||
// — not asserted here, but greppable in operational logs.
|
||||
const response = await request.post(
|
||||
`${CONFIG.apiURL}/api/v1/auth/verify-email?token=e2e-bogus-but-non-empty-legacy-token`,
|
||||
);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
const message: string =
|
||||
body?.error?.message ?? body?.error ?? body?.message ?? '';
|
||||
expect(message.toLowerCase()).not.toBe('token required');
|
||||
expect(message.toLowerCase()).toMatch(
|
||||
/verification|invalid|expired|email/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1613,7 +1613,7 @@ const docTemplate = `{
|
|||
},
|
||||
"/auth/verify-email": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue