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:
@@ -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')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: 'JWT refresh token' })
|
||||
@ApiPropertyOptional({ description: 'JWT refresh token (optional if sent via cookie)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user