Set up PostgreSQL database configuration and enhance application structure
- Added .env.example with database connection details and JWT configuration. - Introduced docker-compose.yml for PostgreSQL service setup with health checks. - Created drizzle.config.ts for database schema and migration management. - Updated nest-cli.json to include Swagger plugin configuration for API documentation. - Enhanced package.json with new database-related scripts and dependencies. - Implemented initial database migrations for user and token management. - Configured application bootstrap process to load environment variables and set up Swagger. - Added shared application configuration in configure-app.ts for consistent setup. - Included unit tests for application configuration and Swagger setup.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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<TokenPair> {
|
||||
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);
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user