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:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TokenService,
|
||||
type JwtPayload,
|
||||
type TokenPair,
|
||||
type RotateResult,
|
||||
} from './token.service';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user