- Introduced new columns `status`, `created_by`, and `updated_by` in the `users` table to track user status and ownership. - Updated the `employees` table to include a foreign key reference to the `users` table via `user_id`. - Created migration script `0012_users_primary.sql` to apply these changes to the database schema. - Enhanced the `EmployeesService` and `EmployeesRepository` to support user assignments and related data retrieval. - Updated DTOs and service methods to reflect the new user and employee relationships. - Added unit tests to validate the new functionality and ensure data integrity. - Modified existing controllers to accommodate the new fields and relationships in user and employee management.
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
import type { JwtAccessPayload } from '../../common/auth/auth-user';
|
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
|
import type { User } from '../users/user';
|
|
import { UsersService } from '../users/users.service';
|
|
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
|
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
|
|
|
export type TokenPair = {
|
|
readonly accessToken: string;
|
|
readonly refreshToken: string;
|
|
};
|
|
|
|
/** Precomputed bcrypt hash used only to equalize login timing on unknown users. */
|
|
const DUMMY_PASSWORD_HASH =
|
|
'$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly usersService: UsersService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly config: ConfigService,
|
|
private readonly refreshTokensRepository: RefreshTokensRepository,
|
|
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
|
) {}
|
|
|
|
async register(
|
|
username: string,
|
|
password: string,
|
|
): Promise<{ id: string; username: string; status: string }> {
|
|
const existing = await this.usersService.findByUsername(username);
|
|
if (existing) {
|
|
throw new ConflictException('Username already registered');
|
|
}
|
|
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
|
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
const user = await this.usersService.create(username, passwordHash);
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
status: user.status.value,
|
|
};
|
|
}
|
|
|
|
async login(username: string, password: string): Promise<TokenPair> {
|
|
const user = await this.usersService.findByUsername(username);
|
|
const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
|
const match = await bcrypt.compare(password, passwordHash);
|
|
if (!user || !match) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
this.usersService.assertCanAuthenticate(user);
|
|
const { tokens } = await this.issueTokenPair(user);
|
|
return tokens;
|
|
}
|
|
|
|
async refresh(refreshToken: string): Promise<TokenPair> {
|
|
const tokenHash = this.hashRefreshToken(refreshToken);
|
|
const claimed =
|
|
await this.refreshTokensRepository.claimForRotation(tokenHash);
|
|
|
|
if (!claimed) {
|
|
const existing =
|
|
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
|
if (existing) {
|
|
await this.revokeAllSessionsForUser(existing.userId);
|
|
}
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
|
|
if (claimed.expiresAt.value <= Date.now()) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
|
|
const user = await this.usersService.findById(claimed.userId);
|
|
if (!user) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
this.usersService.assertCanAuthenticate(user);
|
|
|
|
await this.denylistAccessJti(claimed.accessJti);
|
|
const issued = await this.issueTokenPair(user);
|
|
await this.refreshTokensRepository.setReplacedBy(
|
|
claimed.id,
|
|
issued.sessionId,
|
|
);
|
|
|
|
return issued.tokens;
|
|
}
|
|
|
|
async revoke(refreshToken: string): Promise<void> {
|
|
const tokenHash = this.hashRefreshToken(refreshToken);
|
|
const session =
|
|
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
|
|
|
if (!session || session.revokedAt !== null) {
|
|
return;
|
|
}
|
|
|
|
const claimed = await this.refreshTokensRepository.markRevoked(session.id);
|
|
if (claimed) {
|
|
await this.denylistAccessJti(session.accessJti);
|
|
}
|
|
}
|
|
|
|
async isAccessJtiRevoked(jti: string): Promise<boolean> {
|
|
return this.revokedAccessTokensRepository.exists(jti);
|
|
}
|
|
|
|
private async revokeAllSessionsForUser(userId: string): Promise<void> {
|
|
const revoked = await this.refreshTokensRepository.revokeAllForUser(userId);
|
|
await Promise.all(
|
|
revoked.map((session) => this.denylistAccessJti(session.accessJti)),
|
|
);
|
|
}
|
|
|
|
private async issueTokenPair(
|
|
user: User,
|
|
): Promise<{ tokens: TokenPair; sessionId: string }> {
|
|
const jti = randomUUID();
|
|
const payload: JwtAccessPayload = {
|
|
sub: user.id,
|
|
username: user.username,
|
|
jti,
|
|
typ: 'access',
|
|
};
|
|
|
|
const expiresIn = this.config.getOrThrow<string>('JWT_ACCESS_EXPIRES_IN');
|
|
const accessToken = await this.jwtService.signAsync(
|
|
{ ...payload },
|
|
{ expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}` },
|
|
);
|
|
|
|
const refreshToken = randomBytes(32).toString('hex');
|
|
const refreshMs = this.config.getOrThrow<number>(
|
|
'REFRESH_TOKEN_EXPIRES_IN_MS',
|
|
);
|
|
const expiresAt = DateTime.fromUnixMs(Date.now() + refreshMs);
|
|
|
|
const session = await this.refreshTokensRepository.create({
|
|
userId: user.id,
|
|
tokenHash: this.hashRefreshToken(refreshToken),
|
|
accessJti: jti,
|
|
expiresAt,
|
|
});
|
|
|
|
return {
|
|
tokens: { accessToken, refreshToken },
|
|
sessionId: session.id,
|
|
};
|
|
}
|
|
|
|
private hashRefreshToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
private async denylistAccessJti(jti: string): Promise<void> {
|
|
const ttlMs = this.config.getOrThrow<number>('JWT_ACCESS_EXPIRES_IN_MS');
|
|
const expiresAt = DateTime.fromUnixMs(Date.now() + ttlMs);
|
|
await this.revokedAccessTokensRepository.add(jti, expiresAt);
|
|
}
|
|
}
|