feat(auth): implement Auth module with register, login, JWT, guards, and CQRS

- Add RefreshToken and OAuthAccount models to Prisma schema
- Implement clean architecture: domain (entities, VOs, events, repo interfaces),
  infrastructure (Prisma repos, Passport strategies, token service),
  application (CQRS command/query handlers), presentation (controller, guards, DTOs)
- Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile,
  GET /auth/profile/agent, PATCH /auth/kyc
- JWT access + refresh token rotation with family-based revocation
- Role-based guards (BUYER, SELLER, AGENT, ADMIN)
- 16 unit tests (value objects, entity) + integration test suite
- All 80 tests passing, clean TypeScript build

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 00:24:42 +07:00
parent c981bff771
commit 391c040100
63 changed files with 2194 additions and 33 deletions

View File

@@ -0,0 +1,6 @@
export {
TokenService,
type JwtPayload,
type TokenPair,
type RotateResult,
} from './token.service';

View File

@@ -0,0 +1,127 @@
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomBytes, createHash } from 'crypto';
import {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,
} from '../../domain/repositories/refresh-token.repository';
export interface JwtPayload {
sub: string;
phone: string;
role: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface RotateResult {
userId: string;
refreshToken: string;
}
@Injectable()
export class TokenService {
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
constructor(
private readonly jwtService: JwtService,
@Inject(REFRESH_TOKEN_REPOSITORY)
private readonly refreshTokenRepo: IRefreshTokenRepository,
) {}
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
const accessToken = this.jwtService.sign(payload);
const rawRefreshToken = randomBytes(64).toString('hex');
const hashedToken = this.hashToken(rawRefreshToken);
const family = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: payload.sub,
token: hashedToken,
family,
expiresAt,
revokedAt: null,
});
return {
accessToken,
refreshToken: `${family}.${rawRefreshToken}`,
expiresIn: 900,
};
}
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
const dotIndex = refreshToken.indexOf('.');
if (dotIndex === -1) return null;
const family = refreshToken.substring(0, dotIndex);
const rawToken = refreshToken.substring(dotIndex + 1);
if (!family || !rawToken) return null;
const hashedToken = this.hashToken(rawToken);
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
if (!existing) {
// Possible token reuse attack — revoke entire family
await this.refreshTokenRepo.revokeByFamily(family);
return null;
}
if (existing.revokedAt || existing.expiresAt < new Date()) {
await this.refreshTokenRepo.revokeByFamily(existing.family);
return null;
}
// Revoke all tokens in this family
await this.refreshTokenRepo.revokeByFamily(existing.family);
// Create new token in a new family
const newRawToken = randomBytes(64).toString('hex');
const newHashedToken = this.hashToken(newRawToken);
const newFamily = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: existing.userId,
token: newHashedToken,
family: newFamily,
expiresAt,
revokedAt: null,
});
return {
userId: existing.userId,
refreshToken: `${newFamily}.${newRawToken}`,
};
}
generateAccessToken(payload: JwtPayload): string {
return this.jwtService.sign(payload);
}
async revokeAllUserTokens(userId: string): Promise<void> {
await this.refreshTokenRepo.revokeAllForUser(userId);
}
verifyAccessToken(token: string): JwtPayload | null {
try {
return this.jwtService.verify<JwtPayload>(token);
} catch {
return null;
}
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}