feat: add authorization

This commit is contained in:
2026-08-13 15:03:51 +03:00
parent ab21426478
commit 4cbfc2621c
25 changed files with 952 additions and 114 deletions
+138
View File
@@ -0,0 +1,138 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UseGuards,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { CredentialsDto } from './auth.dto';
import { AuthService } from './auth.service';
import { AccessPayload } from './auth.types';
import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth';
class UserDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
}
class AuthenticationDto {
@ApiProperty()
accessToken: string;
@ApiProperty({ type: UserDto })
user: UserDto;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
@ApiOkResponse({ type: AuthenticationDto })
async register(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.register(credentials.email, credentials.password),
);
}
@Post('login')
@ApiOkResponse({ type: AuthenticationDto })
async login(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.login(credentials.email, credentials.password),
);
}
@Post('refresh')
@ApiOkResponse({ type: AuthenticationDto })
async refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
throw error;
}
}
@Post('logout')
@HttpCode(204)
@ApiNoContentResponse()
async logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
await this.auth.logout(this.refreshToken(request));
this.clearCookie(response);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserDto })
async me(@Req() request: Request & { user: AccessPayload }) {
const user = await this.auth.userById(request.user.sub);
return this.auth.toUser(user);
}
private respond(
response: Response,
result: {
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken };
}
private clearCookie(response: Response): void {
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
}
private refreshToken(request: Request): string | undefined {
const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined;
}
private cookieOptions() {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH,
};
}
}
+57
View File
@@ -0,0 +1,57 @@
import {
IsString,
MinLength,
Validate,
ValidatorConstraint,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@ValidatorConstraint({ name: 'email' })
class EmailConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length <= 254 &&
value.split('@').length === 2 &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127,
) &&
value.split('@').every(Boolean)
);
}
defaultMessage(): string {
return 'must be a valid email address';
}
}
@ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint {
validate(value: unknown): boolean {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
}
defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes';
}
}
export class CredentialsDto {
@ApiProperty({ example: 'user@example.com' })
@IsString()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@Validate(EmailConstraint)
email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
@IsString()
@MinLength(12)
@Validate(PasswordBytesConstraint)
password: string;
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { Environment } from '../config/env.validation';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
secret: config.getOrThrow('JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
})
export class AuthModule {}
+184
View File
@@ -0,0 +1,184 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types';
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH =
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async register(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const normalizedEmail = this.normalizeEmail(email);
const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing)
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
const user = await this.prisma.user.create({
data: {
id: randomUUID(),
email: normalizedEmail,
passwordHash: await bcrypt.hash(password, 12),
},
});
return this.createSession(user);
}
async login(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: this.normalizeEmail(email) },
});
const valid = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !valid)
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
return this.createSession(user);
}
async refresh(
token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
include: { user: true },
});
if (
!session ||
session.userId !== payload.sub ||
session.revokedAt ||
session.expiresAt <= new Date()
) {
if (session && !session.revokedAt)
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
if (session.refreshTokenHash !== this.hash(token)) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
return this.rotateSession(session);
}
async logout(token?: string): Promise<void> {
if (!token) return;
try {
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
} catch {
// Logout is intentionally idempotent.
}
}
toUser(user: User): AuthUser {
return { id: user.id, email: user.email };
}
async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return user;
}
private async createSession(
user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const session = await this.prisma.session.create({
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user });
}
private async rotateSession(
session: Session & { user: User },
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const refreshToken = await this.jwt.signAsync(
{
sub: session.userId,
sid: session.id,
type: 'refresh',
jti: randomUUID(),
},
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
);
await this.prisma.session.update({
where: { id: session.id },
data: {
refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return {
user: this.toUser(session.user),
accessToken: await this.jwt.signAsync(
{ sub: session.userId, sid: session.id, type: 'access' },
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
),
refreshToken,
};
}
private normalizeEmail(email: string): string {
return email;
}
private hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}
+17
View File
@@ -0,0 +1,17 @@
export interface AuthUser {
id: string;
email: string;
}
export interface AccessPayload {
sub: string;
sid: string;
type: 'access';
}
export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+24
View File
@@ -0,0 +1,24 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../config/env.validation';
import { AccessPayload } from './auth.types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService<Environment, true>) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow('JWT_SECRET'),
algorithms: ['HS256'],
});
}
validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return payload;
}
}