fix(auth): migrate tokens from localStorage to httpOnly cookies + CSRF hardening

Backend:
- Auth controller sets httpOnly secure cookies (access_token, refresh_token, goodgo_authenticated) on login/register/refresh
- JWT strategy reads token from cookie first, falls back to Authorization header
- Added POST /auth/logout to clear auth cookies
- Added POST /auth/exchange-token for OAuth callback token-to-cookie exchange
- Refresh endpoint reads refresh_token from cookie (body fallback for backwards compat)
- CSRF middleware excludes auth endpoints (login, register, refresh, exchange-token, logout)

Frontend:
- Removed all localStorage token storage (goodgo_tokens key)
- Removed authGet/authPost/authPatch helpers from api-client (tokens sent via cookies)
- All API calls use credentials:'include' for cookie-based auth
- Updated auth-store: no more token state, uses isAuthenticated flag from cookie
- Updated admin-api, listings-api to remove explicit token parameters
- Updated all pages (admin dashboard, users, KYC, moderation, listings) to remove token passing
- OAuth callbacks use exchange-token endpoint to convert URL tokens to cookies
- Auth provider simplified (no client-side cookie management needed)

Security improvements:
- JWT no longer accessible via JavaScript (XSS-safe)
- Refresh token scoped to /auth path only
- Server-side goodgo_authenticated cookie with SameSite=Lax
- Access token cookie with SameSite=Strict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:25:11 +07:00
parent 9583d1cb66
commit 6389dcf78e
19 changed files with 151 additions and 191 deletions

View File

@@ -28,7 +28,7 @@ import { LocalAuthGuard } from '../guards/local-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
import { TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
@@ -73,6 +73,7 @@ export class AuthController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly tokenService: TokenService,
) {}
@Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } })
@@ -140,6 +141,26 @@ export class AuthController {
return { message: 'Đã đăng xuất' };
}
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
@ApiResponse({ status: 201, description: 'Auth cookies set' })
@ApiResponse({ status: 401, description: 'Invalid access token' })
async exchangeToken(
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
@Res({ passthrough: true }) res: Response,
): Promise<{ message: string }> {
const payload = this.tokenService.verifyAccessToken(body.accessToken);
if (!payload) {
throw new UnauthorizedException('Invalid access token');
}
setAuthCookies(res, {
accessToken: body.accessToken,
refreshToken: body.refreshToken,
expiresIn: body.expiresIn ?? 900,
});
return { message: 'Auth cookies set' };
}
@UseGuards(JwtAuthGuard)
@Get('profile')
@ApiBearerAuth('JWT')