chore: strengthen backend eslint profile
This commit is contained in:
@@ -114,8 +114,8 @@ describe('AuthService', () => {
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id?: string; refreshTokenHash?: string };
|
||||
data: { refreshTokenHash: string };
|
||||
where: { id?: string; refreshTokenHash?: string };
|
||||
}) => {
|
||||
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
||||
currentHash = data.refreshTokenHash;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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';
|
||||
@@ -16,6 +17,7 @@ const REFRESH_TTL_MS = 604_800_000;
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
|
||||
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||
|
||||
@Injectable()
|
||||
@@ -28,12 +30,13 @@ export class AuthService {
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
if (existing)
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
}
|
||||
|
||||
let user: User;
|
||||
try {
|
||||
@@ -45,8 +48,9 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error))
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.createSession(user);
|
||||
@@ -55,7 +59,7 @@ export class AuthService {
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
@@ -63,14 +67,15 @@ export class AuthService {
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !valid)
|
||||
if (!user || !valid) {
|
||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async refresh(
|
||||
token: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
@@ -79,8 +84,9 @@ export class AuthService {
|
||||
} catch {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (payload.type !== 'refresh')
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
include: { user: true },
|
||||
@@ -91,12 +97,14 @@ export class AuthService {
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= new Date()
|
||||
) {
|
||||
if (session && !session.revokedAt)
|
||||
if (session && !session.revokedAt) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
}
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (session.refreshTokenHash !== this.hash(token))
|
||||
if (session.refreshTokenHash !== this.hash(token)) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return this.rotateSession(session, session.refreshTokenHash);
|
||||
}
|
||||
|
||||
@@ -138,7 +146,7 @@ export class AuthService {
|
||||
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
@@ -153,7 +161,7 @@ export class AuthService {
|
||||
private async rotateSession(
|
||||
session: Session & { user: User },
|
||||
expectedRefreshTokenHash?: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: session.userId,
|
||||
@@ -172,8 +180,9 @@ export class AuthService {
|
||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||
data,
|
||||
});
|
||||
if (updated.count !== 1)
|
||||
if (updated.count !== 1) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AccessPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
sub: string;
|
||||
type: 'access';
|
||||
}
|
||||
|
||||
export interface RefreshPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
type: 'refresh';
|
||||
jti: string;
|
||||
sid: string;
|
||||
sub: string;
|
||||
type: 'refresh';
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ 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 './presentation/auth.controller';
|
||||
import { AuthService } from './application/auth.service';
|
||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||
import { AuthController } from './presentation/auth.controller';
|
||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 '../application/auth.types';
|
||||
|
||||
@@ -17,8 +18,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
validate(payload: AccessPayload): AccessPayload {
|
||||
if (payload.type !== 'access')
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -17,11 +19,11 @@ import {
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
@@ -115,9 +117,9 @@ export class AuthController {
|
||||
private respond(
|
||||
response: Response,
|
||||
result: {
|
||||
user: { id: string; email: string };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { email: string; id: string };
|
||||
},
|
||||
) {
|
||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||
@@ -129,6 +131,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
private refreshToken(request: Request): string | undefined {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
|
||||
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
||||
return typeof token === 'string' ? token : undefined;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ class EmailConstraint {
|
||||
character.charCodeAt(0) < ASCII_SPACE ||
|
||||
character.charCodeAt(0) === ASCII_DELETE,
|
||||
) &&
|
||||
value.split('@').every(Boolean)
|
||||
value.split('@').every(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'must be a valid email address';
|
||||
}
|
||||
@@ -44,6 +45,7 @@ class PasswordBytesConstraint {
|
||||
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: AccessPayload }>();
|
||||
if (request.user?.sub)
|
||||
if (request.user?.sub) {
|
||||
this.requestContext.set({ userId: request.user.sub });
|
||||
}
|
||||
return Boolean(allowed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user