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:
shancheas
2026-08-21 15:00:19 +07:00
parent 7f37985d22
commit d01fd6e2ef
54 changed files with 4378 additions and 87 deletions
+163
View File
@@ -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);
}
}