fix(backend): Refresh-Token als httpOnly-Cookie statt Response-Body
ARCHITECTURE.md §3 legt fest: Access-Token im Memory/Context, Refresh-Token
als httpOnly-Cookie. Vorherige Implementierung gab beide Tokens im JSON-Body
zurueck - vor dem Frontend-Aufbau (das genau gegen diesen Vertrag baut)
korrigiert.
/api/v1/auth/login und /refresh setzen jetzt ein httpOnly-Cookie
(Path /api/v1/auth, SameSite=Lax, secure in production) und liefern nur
noch { accessToken } im Body. /refresh und /logout lesen den Refresh-Token
aus dem Cookie statt aus dem Request-Body.
e2e-Tests und Live-Smoke-Test (curl mit Cookie-Jar) verifizieren
Login/Rotation/Logout-Invalidierung. 12 e2e- + 26 Unit-Tests, Lint,
Typecheck und Build gruen.
This commit is contained in:
1 parent
ae8f6d4435
commit
370102387f
8 files changed
+143
-36
No files matched your search
@@ -5,9 +5,11 @@ import {
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
export function configureApp(app: INestApplication): void {
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
|
||||
@@ -1,34 +1,90 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { AuthTokensDto } from './dto/auth-tokens.dto';
|
||||
import { AccessTokenDto } from './dto/access-token.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { parseDurationToMs } from './util/duration';
|
||||
|
||||
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||
});
|
||||
}
|
||||
|
||||
private clearRefreshTokenCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||
}
|
||||
|
||||
private extractRefreshToken(req: Request): string {
|
||||
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
||||
REFRESH_TOKEN_COOKIE
|
||||
];
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() dto: LoginDto): Promise<AuthTokensDto> {
|
||||
return this.authService.login(dto.email, dto.password);
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto> {
|
||||
const { accessToken, refreshToken } = await this.authService.login(
|
||||
dto.email,
|
||||
dto.password,
|
||||
);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto> {
|
||||
const { accessToken, refreshToken } = await this.authService.refresh(
|
||||
this.extractRefreshToken(req),
|
||||
);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
await this.authService.logout(this.extractRefreshToken(req));
|
||||
this.clearRefreshTokenCookie(res);
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -1,9 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AuthTokensDto {
|
||||
export class AccessTokenDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
Reference in new issue
Block a user