156 lines
5.2 KiB
TypeScript
156 lines
5.2 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
HttpCode,
|
|
HttpStatus,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
|
import { Request, Response } from 'express';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
|
import { Public } from '../../common/decorators/public.decorator';
|
|
import {
|
|
ChangePasswordDtoSchema,
|
|
LoginDtoSchema,
|
|
RefreshDtoSchema,
|
|
RegisterDtoSchema,
|
|
} from './dto/auth.dto';
|
|
import { AuthService } from './auth.service';
|
|
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
|
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
|
|
|
@ApiTags('auth')
|
|
@Controller('api/v1/auth')
|
|
export class AuthController {
|
|
constructor(
|
|
private readonly auth: AuthService,
|
|
private readonly csrf: CsrfMiddleware,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
@Public()
|
|
@Post('register')
|
|
@ApiOperation({ summary: 'Public player self-registration' })
|
|
@ApiResponse({ status: 201, description: 'Registration successful, returns login session' })
|
|
async register(
|
|
@Body(new ZodValidationPipe(RegisterDtoSchema)) body: {
|
|
username: string;
|
|
password: string;
|
|
passwordConfirm: string;
|
|
},
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
) {
|
|
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
|
const session = await this.auth.registerPlayer(body, ip);
|
|
setRefreshCookie(res, session.refreshToken, this.config);
|
|
return {
|
|
accessToken: session.accessToken,
|
|
expiresIn: session.expiresIn,
|
|
user: session.user,
|
|
};
|
|
}
|
|
|
|
@Public()
|
|
@Post('login')
|
|
@ApiOperation({ summary: 'Login with username and password' })
|
|
@ApiBody({ schema: { example: { username: 'admin', password: 'SuperSecret123!' } } })
|
|
@ApiResponse({ status: 200, description: 'Login successful' })
|
|
async login(
|
|
@Body(new ZodValidationPipe(LoginDtoSchema)) body: { username: string; password: string },
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
) {
|
|
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
|
const session = await this.auth.login(body, ip);
|
|
setRefreshCookie(res, session.refreshToken, this.config);
|
|
return {
|
|
accessToken: session.accessToken,
|
|
expiresIn: session.expiresIn,
|
|
user: session.user,
|
|
};
|
|
}
|
|
|
|
@Public()
|
|
@Post('refresh')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({ summary: 'Rotate the refresh token' })
|
|
async refresh(
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
@Body(new ZodValidationPipe(RefreshDtoSchema)) body: { refreshToken?: string },
|
|
) {
|
|
const fromCookie = (req.cookies?.['rt'] as string | undefined) ?? '';
|
|
const token = body.refreshToken ?? fromCookie;
|
|
if (!token) return { message: 'No refresh token' };
|
|
const session = await this.auth.refresh(token);
|
|
setRefreshCookie(res, session.refreshToken, this.config);
|
|
return {
|
|
accessToken: session.accessToken,
|
|
expiresIn: session.expiresIn,
|
|
user: session.user,
|
|
};
|
|
}
|
|
|
|
@Public()
|
|
@Post('logout')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@ApiOperation({ summary: 'Invalidate the current refresh token' })
|
|
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
|
|
const token = (req.cookies?.['rt'] as string | undefined) ?? '';
|
|
await this.auth.logout(token || undefined);
|
|
clearRefreshCookie(res);
|
|
}
|
|
|
|
@Get('me')
|
|
@ApiOperation({ summary: 'Current authenticated user profile (id, username, role, rank, points)' })
|
|
@ApiResponse({ status: 200, description: 'Returns the current user projection.' })
|
|
async me(@Req() req: Request): Promise<{
|
|
id: string;
|
|
username: string;
|
|
role: 'admin' | 'player';
|
|
rank: number | null;
|
|
points: number;
|
|
}> {
|
|
const user = req.user as { sub: string } | undefined;
|
|
const userId = user?.sub;
|
|
if (!userId) {
|
|
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
|
|
}
|
|
return this.auth.getMe(userId);
|
|
}
|
|
|
|
@Post('change-password')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@ApiOperation({ summary: 'Change the current user password (requires old password)' })
|
|
@ApiBody({
|
|
schema: {
|
|
example: { oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' },
|
|
},
|
|
})
|
|
async changePassword(
|
|
@Req() req: Request,
|
|
@Body(new ZodValidationPipe(ChangePasswordDtoSchema))
|
|
body: { oldPassword: string; newPassword: string; confirmNewPassword: string },
|
|
): Promise<void> {
|
|
const user = req.user as { sub: string } | undefined;
|
|
const userId = user?.sub;
|
|
if (!userId) {
|
|
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
|
|
}
|
|
await this.auth.changePassword(userId, body);
|
|
}
|
|
|
|
@Public()
|
|
@Get('csrf')
|
|
@ApiOperation({ summary: 'Issue a CSRF token (sets csrf cookie, returns token)' })
|
|
@ApiResponse({ status: 200, schema: { example: { csrfToken: '...' } } })
|
|
csrfEndpoint(@Req() req: Request, @Res({ passthrough: true }) res: Response): { csrfToken: string } {
|
|
const token = this.csrf.setOrGetCsrfToken(req, res);
|
|
return { csrfToken: token };
|
|
}
|
|
} |