Files
backend/src/modules/auth/application/auth.service.ts
T
codex-bot 07cfd08df9
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m44s
refactor: replace deep imports with native node aliases (WealthLedger/backend#165) (#193)
2026-11-04 23:22:46 +03:00

173 lines
5.1 KiB
TypeScript

import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import type { Session, User } from '#generated/prisma/client';
import { HttpErrorCode } from '#src/common/error-code';
import { AuthRepository } from '../infrastructure/auth.repository';
import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const PASSWORD_HASH_ROUNDS = 12;
export 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()
export class AuthService {
constructor(
private readonly repository: AuthRepository,
private readonly jwt: JwtService,
) {}
async register(
email: string,
password: string,
): Promise<AuthenticationResult> {
const existing = await this.repository.findUserByEmail(email);
if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
const user = await this.repository.createUser({
id: randomUUID(),
email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
});
if (!user) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
return this.createSession(user);
}
async login(
email: string,
password: string,
): Promise<AuthenticationResult> {
const user = await this.repository.findUserByEmail(email);
const valid = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !valid) {
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user);
}
async refresh(
token: string,
): Promise<AuthenticationResult> {
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
const session = await this.repository.findSessionWithUser(payload.sid);
if (
!session ||
session.userId !== payload.sub ||
session.revokedAt ||
session.expiresAt <= new Date()
) {
if (session && !session.revokedAt) {
await this.repository.deleteSession(session.id);
}
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return this.rotateSession(session, session.refreshTokenHash);
}
async logout(token?: string): Promise<void> {
if (!token) return;
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
return;
}
if (payload.type !== 'refresh') return;
const session = await this.repository.findSession(payload.sid);
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.repository.revokeSession(session.id);
}
}
toUser(user: User): AuthUser {
return { id: user.id, email: user.email };
}
async userById(id: string): Promise<User> {
const user = await this.repository.findUserById(id);
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user;
}
private async createSession(
user: User,
): Promise<AuthenticationResult> {
const session = await this.repository.createSession(user.id, randomUUID());
return this.rotateSession({ ...session, user });
}
private async rotateSession(
session: Session & { user: User },
expectedRefreshTokenHash?: string,
): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync(
{
sub: session.userId,
sid: session.id,
type: 'refresh',
jti: randomUUID(),
},
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
);
const data = {
refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
};
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
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 hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}