chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
import type { RequestWithContext } from '../common/http/request-context';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentAuth } from './decorators/current-auth.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { SkipCsrf } from './decorators/skip-csrf.decorator';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import type { AuthPrincipal } from '../common/http/request-context';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@Public()
|
||||
@SkipCsrf()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } })
|
||||
login(
|
||||
@Body() dto: LoginDto,
|
||||
@Req() request: RequestWithContext,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.auth.login(dto, request, response);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||
refresh(
|
||||
@Req() request: RequestWithContext,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.auth.refresh(request, response);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
logout(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.auth.logout(principal, request, response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentAuth() principal: AuthPrincipal) {
|
||||
return this.auth.me(principal);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
changePassword(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.auth.changePassword(principal, dto, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AccessTokenGuard } from './guards/access-token.guard';
|
||||
import { CsrfGuard } from './guards/csrf.guard';
|
||||
import { AuthConfigService } from '../common/config/auth-config.service';
|
||||
import { CookieService } from './services/cookie.service';
|
||||
import { PasswordService } from './services/password.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
const providers = [
|
||||
AuthConfigService,
|
||||
CookieService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
AuthService,
|
||||
AccessTokenGuard,
|
||||
CsrfGuard,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({}),
|
||||
PhaseADataModule,
|
||||
AuditModule,
|
||||
AuthorizationModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers,
|
||||
exports: [
|
||||
AuthConfigService,
|
||||
CookieService,
|
||||
PasswordService,
|
||||
TokenService,
|
||||
AccessTokenGuard,
|
||||
CsrfGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,496 @@
|
||||
import { isIP } from 'node:net';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository';
|
||||
import { RolesRepository } from '../core-data/repositories/roles.repository';
|
||||
import { UsersRepository } from '../core-data/repositories/users.repository';
|
||||
import {
|
||||
AuditAction,
|
||||
AuditSource,
|
||||
AuthSession,
|
||||
User,
|
||||
UserStatus,
|
||||
} from '../database/entities';
|
||||
import type { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import { CookieService } from './services/cookie.service';
|
||||
import { PasswordService } from './services/password.service';
|
||||
import {
|
||||
IssuedRefreshToken,
|
||||
TokenService,
|
||||
} from './services/token.service';
|
||||
|
||||
interface RequestMetadata {
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
mustChangePassword: boolean;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface SuccessfulAuthentication {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: IssuedRefreshToken;
|
||||
}
|
||||
|
||||
type LoginOutcome = SuccessfulAuthentication | null;
|
||||
|
||||
type RefreshOutcome =
|
||||
| ({ kind: 'ok' } & SuccessfulAuthentication)
|
||||
| { kind: 'reuse' }
|
||||
| { kind: 'invalid' };
|
||||
|
||||
function invalidCredentials(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Credenciales inválidas',
|
||||
});
|
||||
}
|
||||
|
||||
function invalidSession(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'INVALID_SESSION',
|
||||
message: 'Sesión inválida o vencida',
|
||||
});
|
||||
}
|
||||
|
||||
function requestMetadata(request: RequestWithContext): RequestMetadata {
|
||||
const candidateIp = request.ip || request.socket.remoteAddress || '';
|
||||
const ip = isIP(candidateIp) ? candidateIp : null;
|
||||
const rawUserAgent = request.header('user-agent')?.trim();
|
||||
return {
|
||||
ip,
|
||||
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly users: UsersRepository,
|
||||
private readonly sessions: AuthSessionsRepository,
|
||||
private readonly roles: RolesRepository,
|
||||
private readonly passwords: PasswordService,
|
||||
private readonly tokens: TokenService,
|
||||
private readonly cookies: CookieService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
dto: LoginDto,
|
||||
request: RequestWithContext,
|
||||
response: Response,
|
||||
) {
|
||||
const identifier = dto.identifier.trim().toLowerCase();
|
||||
const candidate = await this.users.findForAuthentication(identifier);
|
||||
const metadata = requestMetadata(request);
|
||||
|
||||
if (!candidate) {
|
||||
await this.passwords.verifyUnknown(dto.password);
|
||||
await this.audit.record({
|
||||
action: AuditAction.AUTH_LOGIN_FAILED,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
metadata: {
|
||||
identifier,
|
||||
reason: 'UNKNOWN_IDENTIFIER',
|
||||
},
|
||||
});
|
||||
throw invalidCredentials();
|
||||
}
|
||||
|
||||
const outcome = await this.dataSource.transaction<LoginOutcome>(
|
||||
async (manager) => {
|
||||
const user = await manager
|
||||
.getRepository(User)
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.id = :id', { id: candidate.id })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const now = new Date();
|
||||
const passwordMatches = await this.passwords.verify(
|
||||
user.passwordHash,
|
||||
dto.password,
|
||||
);
|
||||
const locked = Boolean(user.lockedUntil && user.lockedUntil > now);
|
||||
const active = user.status === UserStatus.ACTIVE;
|
||||
|
||||
if (!passwordMatches || locked || !active) {
|
||||
if (!passwordMatches && active && !locked) {
|
||||
await manager.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET
|
||||
failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_attempts + 1 >= $2
|
||||
THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second')
|
||||
ELSE locked_until
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
user.id,
|
||||
this.tokens.maxLoginAttempts,
|
||||
this.tokens.lockoutSeconds,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AUTH_LOGIN_FAILED,
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
metadata: {
|
||||
reason: !active
|
||||
? 'INACTIVE_USER'
|
||||
: locked
|
||||
? 'LOCKED_USER'
|
||||
: 'INVALID_PASSWORD',
|
||||
},
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.passwords.needsRehash(user.passwordHash)) {
|
||||
user.passwordHash = await this.passwords.hash(dto.password);
|
||||
}
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
user.lastLoginAt = now;
|
||||
await manager.getRepository(User).save(user);
|
||||
|
||||
const refreshToken = this.tokens.issueRefreshToken();
|
||||
const session = manager.getRepository(AuthSession).create({
|
||||
id: refreshToken.sessionId,
|
||||
userId: user.id,
|
||||
refreshTokenHash: refreshToken.tokenHash,
|
||||
expiresAt: this.tokens.refreshExpiresAt(now),
|
||||
lastUsedAt: now,
|
||||
revokedAt: null,
|
||||
replacedBySessionId: null,
|
||||
ip: metadata.ip,
|
||||
userAgent: metadata.userAgent,
|
||||
deviceLabel: dto.deviceLabel?.trim() || null,
|
||||
});
|
||||
await manager.getRepository(AuthSession).save(session);
|
||||
|
||||
const accessToken = await this.tokens.issueAccessToken({
|
||||
userId: user.id,
|
||||
sessionId: session.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||
entityType: 'auth_session',
|
||||
entityId: session.id,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
metadata: { deviceLabel: session.deviceLabel },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return { user, accessToken, refreshToken };
|
||||
},
|
||||
);
|
||||
|
||||
if (!outcome) throw invalidCredentials();
|
||||
return this.completeAuthentication(outcome, response);
|
||||
}
|
||||
|
||||
async refresh(request: RequestWithContext, response: Response) {
|
||||
const rawToken = this.cookies.getRefreshToken(request);
|
||||
const parsed = rawToken ? this.tokens.parseRefreshToken(rawToken) : null;
|
||||
if (!rawToken || !parsed) {
|
||||
this.cookies.clearAuthCookies(response);
|
||||
throw invalidSession();
|
||||
}
|
||||
|
||||
const metadata = requestMetadata(request);
|
||||
const outcome = await this.dataSource.transaction<RefreshOutcome>(
|
||||
async (manager) => {
|
||||
const session = await manager
|
||||
.getRepository(AuthSession)
|
||||
.createQueryBuilder('session')
|
||||
.addSelect('session.refreshTokenHash')
|
||||
.where('session.id = :id', { id: parsed.sessionId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
|
||||
if (
|
||||
!session ||
|
||||
!this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash)
|
||||
) {
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
|
||||
const user = await manager.getRepository(User).findOne({
|
||||
where: { id: session.userId },
|
||||
});
|
||||
|
||||
if (session.revokedAt) {
|
||||
await this.sessions.revokeSessionFamily(session.id, manager);
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: user?.id ?? null,
|
||||
actorUsername: user?.username ?? null,
|
||||
action: AuditAction.AUTH_REFRESH_REUSE_DETECTED,
|
||||
entityType: 'auth_session',
|
||||
entityId: session.id,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return { kind: 'reuse' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) {
|
||||
session.revokedAt = now;
|
||||
session.lastUsedAt = now;
|
||||
await manager.getRepository(AuthSession).save(session);
|
||||
if (user?.status === UserStatus.INACTIVE) {
|
||||
await this.sessions.revokeUserSessions(user.id, undefined, manager);
|
||||
}
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
|
||||
const refreshToken = this.tokens.issueRefreshToken();
|
||||
const replacement = manager.getRepository(AuthSession).create({
|
||||
id: refreshToken.sessionId,
|
||||
userId: user.id,
|
||||
refreshTokenHash: refreshToken.tokenHash,
|
||||
expiresAt: this.tokens.refreshExpiresAt(now),
|
||||
lastUsedAt: now,
|
||||
revokedAt: null,
|
||||
replacedBySessionId: null,
|
||||
ip: metadata.ip,
|
||||
userAgent: metadata.userAgent,
|
||||
deviceLabel: session.deviceLabel,
|
||||
});
|
||||
await manager.getRepository(AuthSession).save(replacement);
|
||||
|
||||
session.revokedAt = now;
|
||||
session.lastUsedAt = now;
|
||||
session.replacedBySessionId = replacement.id;
|
||||
await manager.getRepository(AuthSession).save(session);
|
||||
|
||||
const accessToken = await this.tokens.issueAccessToken({
|
||||
userId: user.id,
|
||||
sessionId: replacement.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AUTH_REFRESH,
|
||||
entityType: 'auth_session',
|
||||
entityId: replacement.id,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
metadata: { replacedSessionId: session.id },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return { kind: 'ok', user, accessToken, refreshToken };
|
||||
},
|
||||
);
|
||||
|
||||
if (outcome.kind !== 'ok') {
|
||||
this.cookies.clearAuthCookies(response);
|
||||
throw invalidSession();
|
||||
}
|
||||
return this.completeAuthentication(outcome, response);
|
||||
}
|
||||
|
||||
async logout(
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
response: Response,
|
||||
) {
|
||||
const metadata = requestMetadata(request);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.sessions.revokeSession(
|
||||
principal.sessionId,
|
||||
principal.userId,
|
||||
manager,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: principal.userId,
|
||||
actorUsername: principal.username,
|
||||
action: AuditAction.AUTH_LOGOUT,
|
||||
entityType: 'auth_session',
|
||||
entityId: principal.sessionId,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
this.cookies.clearAuthCookies(response);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
me(principal: AuthPrincipal) {
|
||||
return { user: this.publicPrincipal(principal) };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
principal: AuthPrincipal,
|
||||
dto: ChangePasswordDto,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const metadata = requestMetadata(request);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const user = await manager
|
||||
.getRepository(User)
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.id = :id', { id: principal.userId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
|
||||
if (
|
||||
!user ||
|
||||
!(await this.passwords.verify(user.passwordHash, dto.currentPassword))
|
||||
) {
|
||||
throw invalidCredentials();
|
||||
}
|
||||
if (await this.passwords.verify(user.passwordHash, dto.newPassword)) {
|
||||
throw new BadRequestException({
|
||||
code: 'PASSWORD_UNCHANGED',
|
||||
message: 'La nueva contraseña debe ser diferente',
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
user.passwordHash = await this.passwords.hash(dto.newPassword);
|
||||
user.passwordChangedAt = now;
|
||||
user.mustChangePassword = false;
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
user.updatedBy = user.id;
|
||||
await manager.getRepository(User).save(user);
|
||||
await this.sessions.revokeUserSessions(
|
||||
user.id,
|
||||
principal.sessionId,
|
||||
manager,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
actorUserId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AUTH_PASSWORD_CHANGED,
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
...metadata,
|
||||
metadata: { otherSessionsRevoked: true },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return { status: 'ok', mustChangePassword: false };
|
||||
}
|
||||
|
||||
private async completeAuthentication(
|
||||
authentication: SuccessfulAuthentication,
|
||||
response: Response,
|
||||
) {
|
||||
const [roleCodes, permissionCodes] = await Promise.all([
|
||||
this.roles.findRoleCodesForUser(authentication.user.id),
|
||||
this.roles.findPermissionCodesForUser(authentication.user.id),
|
||||
]);
|
||||
const csrfToken = this.cookies.setAuthCookies(
|
||||
response,
|
||||
authentication.accessToken,
|
||||
authentication.refreshToken.token,
|
||||
);
|
||||
|
||||
return {
|
||||
user: this.publicUser(authentication.user, roleCodes, permissionCodes),
|
||||
csrfToken,
|
||||
accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private publicUser(
|
||||
user: User,
|
||||
roles: string[],
|
||||
permissions: string[],
|
||||
): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
private publicPrincipal(principal: AuthPrincipal): PublicUser {
|
||||
return {
|
||||
id: principal.userId,
|
||||
username: principal.username,
|
||||
firstName: principal.firstName,
|
||||
lastName: principal.lastName,
|
||||
email: principal.email,
|
||||
mustChangePassword: principal.mustChangePassword,
|
||||
roles: principal.roles,
|
||||
permissions: principal.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../../common/http/request-context';
|
||||
|
||||
export const CurrentAuth = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthPrincipal => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithContext>();
|
||||
if (!request.auth) throw new Error('Authenticated principal is missing');
|
||||
return request.auth;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const SKIP_CSRF_KEY = 'skipCsrf';
|
||||
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(128)
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(128)
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(320)
|
||||
identifier!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
deviceLabel?: string;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { PermissionResolverService } from '../../authorization/permission-resolver.service';
|
||||
import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository';
|
||||
import { UsersRepository } from '../../core-data/repositories/users.repository';
|
||||
import { UserStatus } from '../../database/entities';
|
||||
import type { AuthTransport, RequestWithContext } from '../../common/http/request-context';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { CookieService } from '../services/cookie.service';
|
||||
import { TokenService } from '../services/token.service';
|
||||
import type { AccessTokenPayload } from '../interfaces/access-token-payload';
|
||||
|
||||
function unauthorized(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Autenticación requerida',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly tokens: TokenService,
|
||||
private readonly cookies: CookieService,
|
||||
private readonly users: UsersRepository,
|
||||
private readonly sessions: AuthSessionsRepository,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithContext>();
|
||||
const extracted = this.extractToken(request);
|
||||
if (!extracted) throw unauthorized();
|
||||
|
||||
let payload: AccessTokenPayload;
|
||||
try {
|
||||
payload = await this.tokens.verifyAccessToken(extracted.token);
|
||||
} catch {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
if (
|
||||
payload.typ !== 'access' ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
typeof payload.sid !== 'string' ||
|
||||
typeof payload.username !== 'string'
|
||||
) {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
const [user, session, authorization] = await Promise.all([
|
||||
this.users.findById(payload.sub),
|
||||
this.sessions.findActiveById(payload.sid),
|
||||
this.permissions.resolveForUser(payload.sub),
|
||||
]);
|
||||
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.ACTIVE ||
|
||||
!session ||
|
||||
session.userId !== user.id
|
||||
) {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
request.auth = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
sessionId: session.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles: authorization.roles,
|
||||
permissions: authorization.permissions,
|
||||
transport: extracted.transport,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(
|
||||
request: Request,
|
||||
): { token: string; transport: AuthTransport } | null {
|
||||
const authorization = request.header('authorization');
|
||||
if (authorization) {
|
||||
const [scheme, token, extra] = authorization.split(' ');
|
||||
if (scheme?.toLowerCase() === 'bearer' && token && !extra) {
|
||||
return { token, transport: 'bearer' };
|
||||
}
|
||||
}
|
||||
|
||||
const cookieToken = this.cookies.getAccessToken(request);
|
||||
return cookieToken ? { token: cookieToken, transport: 'cookie' } : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { RequestWithContext } from '../../common/http/request-context';
|
||||
import { AuthConfigService } from '../../common/config/auth-config.service';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { SKIP_CSRF_KEY } from '../decorators/skip-csrf.decorator';
|
||||
import { CookieService } from '../services/cookie.service';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
@Injectable()
|
||||
export class CsrfGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly cookies: CookieService,
|
||||
private readonly config: AuthConfigService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<RequestWithContext>();
|
||||
if (SAFE_METHODS.has(request.method.toUpperCase())) return true;
|
||||
if (request.auth?.transport === 'bearer') return true;
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (
|
||||
!isPublic &&
|
||||
/^Bearer\s+\S+$/i.test(request.header('authorization') ?? '')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const origin = request.header('origin');
|
||||
if (origin && origin !== this.config.webOrigin) throw this.invalidCsrf();
|
||||
const skipToken = this.reflector.getAllAndOverride<boolean>(SKIP_CSRF_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (skipToken) return true;
|
||||
if (!this.cookies.csrfMatches(request)) throw this.invalidCsrf();
|
||||
return true;
|
||||
}
|
||||
|
||||
private invalidCsrf(): ForbiddenException {
|
||||
return new ForbiddenException({
|
||||
code: 'CSRF_INVALID',
|
||||
message: 'Verificación CSRF inválida',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface AccessTokenPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
username: string;
|
||||
typ: 'access';
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AuthConfigService } from '../../common/config/auth-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class CookieService {
|
||||
constructor(private readonly config: AuthConfigService) {}
|
||||
|
||||
setAuthCookies(
|
||||
response: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
): string {
|
||||
response.cookie(this.config.accessCookieName, accessToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: this.config.accessTokenTtlSeconds * 1000,
|
||||
});
|
||||
response.cookie(this.config.refreshCookieName, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v3/auth',
|
||||
maxAge: this.config.refreshTokenTtlSeconds * 1000,
|
||||
});
|
||||
return this.rotateCsrfCookie(response);
|
||||
}
|
||||
|
||||
rotateCsrfCookie(response: Response): string {
|
||||
const csrfToken = randomBytes(32).toString('base64url');
|
||||
response.cookie(this.config.csrfCookieName, csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: this.config.refreshTokenTtlSeconds * 1000,
|
||||
});
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
clearAuthCookies(response: Response): void {
|
||||
response.clearCookie(this.config.accessCookieName, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
response.clearCookie(this.config.refreshCookieName, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v3/auth',
|
||||
});
|
||||
response.clearCookie(this.config.csrfCookieName, {
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
getAccessToken(request: Request): string | undefined {
|
||||
return this.readCookie(request, this.config.accessCookieName);
|
||||
}
|
||||
|
||||
getRefreshToken(request: Request): string | undefined {
|
||||
return this.readCookie(request, this.config.refreshCookieName);
|
||||
}
|
||||
|
||||
csrfMatches(request: Request): boolean {
|
||||
const cookieValue = this.readCookie(request, this.config.csrfCookieName);
|
||||
const headerValue = request.header('x-csrf-token');
|
||||
if (!cookieValue || !headerValue) return false;
|
||||
const cookieBuffer = Buffer.from(cookieValue, 'utf8');
|
||||
const headerBuffer = Buffer.from(headerValue, 'utf8');
|
||||
return (
|
||||
cookieBuffer.length === headerBuffer.length &&
|
||||
timingSafeEqual(cookieBuffer, headerBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
private readCookie(request: Request, name: string): string | undefined {
|
||||
const cookies = request.cookies as Record<string, unknown> | undefined;
|
||||
const value = cookies?.[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
const HASH_OPTIONS: argon2.HashOptions = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 1,
|
||||
hashLength: 32,
|
||||
};
|
||||
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=1,t=3$aTSorDuRpmrZ/ILFbcoRYQ$sjvsmuiBlN3oTS1qeUiYxSShzsEVWiNnMY4Jo5MQw2c';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
hash(password: string): Promise<string> {
|
||||
return argon2.hash(password, HASH_OPTIONS);
|
||||
}
|
||||
|
||||
async verify(hash: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyUnknown(password: string): Promise<void> {
|
||||
await this.verify(DUMMY_PASSWORD_HASH, password);
|
||||
}
|
||||
|
||||
needsRehash(hash: string): boolean {
|
||||
return argon2.needsRehash(hash, {
|
||||
memoryCost: HASH_OPTIONS.memoryCost,
|
||||
timeCost: HASH_OPTIONS.timeCost,
|
||||
parallelism: HASH_OPTIONS.parallelism,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
createHmac,
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthConfigService } from '../../common/config/auth-config.service';
|
||||
import type { AccessTokenPayload } from '../interfaces/access-token-payload';
|
||||
|
||||
const REFRESH_TOKEN_PATTERN =
|
||||
/^(?<sessionId>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(?<secret>[A-Za-z0-9_-]{64})$/i;
|
||||
|
||||
export interface IssuedRefreshToken {
|
||||
sessionId: string;
|
||||
token: string;
|
||||
tokenHash: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: AuthConfigService,
|
||||
) {}
|
||||
|
||||
get accessTokenTtlSeconds(): number {
|
||||
return this.config.accessTokenTtlSeconds;
|
||||
}
|
||||
|
||||
get maxLoginAttempts(): number {
|
||||
return this.config.maxLoginAttempts;
|
||||
}
|
||||
|
||||
get lockoutSeconds(): number {
|
||||
return this.config.lockoutSeconds;
|
||||
}
|
||||
|
||||
refreshExpiresAt(from = new Date()): Date {
|
||||
return new Date(from.getTime() + this.config.refreshTokenTtlSeconds * 1000);
|
||||
}
|
||||
|
||||
issueAccessToken(input: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const payload: AccessTokenPayload = {
|
||||
sub: input.userId,
|
||||
sid: input.sessionId,
|
||||
username: input.username,
|
||||
typ: 'access',
|
||||
};
|
||||
|
||||
return this.jwtService.signAsync(payload, {
|
||||
secret: this.config.accessTokenSecret,
|
||||
algorithm: 'HS256',
|
||||
expiresIn: this.config.accessTokenTtlSeconds,
|
||||
issuer: 'dhv2-api',
|
||||
audience: 'dhv2',
|
||||
});
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): Promise<AccessTokenPayload> {
|
||||
return this.jwtService.verifyAsync<AccessTokenPayload>(token, {
|
||||
secret: this.config.accessTokenSecret,
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'dhv2-api',
|
||||
audience: 'dhv2',
|
||||
});
|
||||
}
|
||||
|
||||
issueRefreshToken(): IssuedRefreshToken {
|
||||
const sessionId = randomUUID();
|
||||
const secret = randomBytes(48).toString('base64url');
|
||||
const token = `${sessionId}.${secret}`;
|
||||
return { sessionId, token, tokenHash: this.hashRefreshToken(token) };
|
||||
}
|
||||
|
||||
parseRefreshToken(token: string): { sessionId: string } | null {
|
||||
const match = REFRESH_TOKEN_PATTERN.exec(token);
|
||||
const sessionId = match?.groups?.sessionId;
|
||||
return sessionId ? { sessionId: sessionId.toLowerCase() } : null;
|
||||
}
|
||||
|
||||
verifyRefreshToken(token: string, expectedHash: string): boolean {
|
||||
const actual = Buffer.from(this.hashRefreshToken(token), 'hex');
|
||||
const expected = Buffer.from(expectedHash, 'hex');
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
private hashRefreshToken(token: string): string {
|
||||
return createHmac('sha256', this.config.refreshTokenPepper)
|
||||
.update(token, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user