Files
trackgo-be/src/modules/auth/auth.service.ts
T
shancheas 8a61c94078 Add user and employee management enhancements with database schema updates
- 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.
2026-08-26 15:29:18 +07:00

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);
}
}