- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration - Login now issues short-lived access token (30m default) + 7d refresh token, both embedding tokenVersion and a typ discriminator - Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped to /auth); refresh token never leaves the cookie - New endpoints: POST /auth/refresh (rotation), GET /auth/me, POST /auth/logout (bumps tokenVersion, revoking all tokens) - JWT strategy accepts bearer or cookie, rejects refresh tokens, and verifies tokenVersion + user existence on every request - Global RolesGuard: authenticated routes require ADMIN unless widened via @Roles(...) - Admin SPA: session fully cookie-based, no token in localStorage; router guard restores session via /auth/me; axios auto-refreshes once on 401; stale localStorage keys cleaned up
184 lines
5.2 KiB
TypeScript
184 lines
5.2 KiB
TypeScript
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { LoginDto } from './dto/login.dto';
|
|
import { RegisterDto } from './dto/register.dto';
|
|
import type { JwtPayload } from './strategies/jwt.strategy';
|
|
|
|
export interface PublicUser {
|
|
id: string;
|
|
username: string;
|
|
role: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface LoginResult {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
user: PublicUser;
|
|
}
|
|
|
|
const BCRYPT_ROUNDS = 10;
|
|
const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
|
|
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
|
|
|
|
/**
|
|
* Compared against when the username does not exist so that login takes
|
|
* the same time either way (prevents user enumeration via timing).
|
|
*/
|
|
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
) {}
|
|
|
|
/**
|
|
* Bootstrap-only registration: allowed just while the instance has no
|
|
* users. Once an admin exists the endpoint refuses to create accounts
|
|
* (use database seeding / an operator flow instead).
|
|
*/
|
|
async register(dto: RegisterDto): Promise<PublicUser> {
|
|
const userCount = await this.prisma.user.count();
|
|
if (userCount > 0) {
|
|
throw new ForbiddenException('Registration is disabled');
|
|
}
|
|
const existing = await this.prisma.user.findUnique({
|
|
where: { username: dto.username },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException('Username already exists');
|
|
}
|
|
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
|
const created = await this.prisma.user.create({
|
|
data: { username: dto.username, passwordHash },
|
|
});
|
|
return this.toPublic(created);
|
|
}
|
|
|
|
/**
|
|
* Verifies credentials and returns signed access + refresh tokens.
|
|
* Both tokens embed the user's tokenVersion so bumping it on the user
|
|
* row (logout / revocation) invalidates them immediately.
|
|
*/
|
|
async login(dto: LoginDto): Promise<LoginResult> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { username: dto.username },
|
|
});
|
|
// Always run a bcrypt compare (against a dummy hash when the user is
|
|
// unknown) so response timing cannot be used to enumerate usernames.
|
|
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
|
|
if (!user || !ok) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
return {
|
|
accessToken: await this.signAccessToken(user),
|
|
refreshToken: await this.signRefreshToken(user),
|
|
user: this.toPublic(user),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rotates a refresh token: the old refresh token becomes invalid as
|
|
* soon as tokenVersion is bumped (logout, revocation).
|
|
*/
|
|
async refresh(refreshToken: string): Promise<LoginResult> {
|
|
let payload: JwtPayload;
|
|
try {
|
|
payload = await this.jwt.verifyAsync(refreshToken);
|
|
} catch {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
if (payload.typ !== 'refresh') {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
const user = await this.prisma.user
|
|
.findUnique({ where: { id: BigInt(payload.sub) } })
|
|
.catch(() => null);
|
|
if (!user || user.tokenVersion !== payload.tv) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
return {
|
|
accessToken: await this.signAccessToken(user),
|
|
refreshToken: await this.signRefreshToken(user),
|
|
user: this.toPublic(user),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Revokes all tokens of a user by bumping tokenVersion.
|
|
*/
|
|
async logout(userId: bigint): Promise<void> {
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: { tokenVersion: { increment: 1 } },
|
|
});
|
|
}
|
|
|
|
async me(userId: bigint): Promise<PublicUser> {
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
return this.toPublic(user);
|
|
}
|
|
|
|
private async signAccessToken(user: {
|
|
id: bigint;
|
|
username: string;
|
|
role: string;
|
|
tokenVersion: number;
|
|
}): Promise<string> {
|
|
const payload: JwtPayload = {
|
|
sub: user.id.toString(),
|
|
username: user.username,
|
|
role: user.role,
|
|
tv: user.tokenVersion,
|
|
typ: 'access',
|
|
};
|
|
return this.jwt.signAsync(payload, {
|
|
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
|
});
|
|
}
|
|
|
|
private async signRefreshToken(user: {
|
|
id: bigint;
|
|
username: string;
|
|
role: string;
|
|
tokenVersion: number;
|
|
}): Promise<string> {
|
|
const payload: JwtPayload = {
|
|
sub: user.id.toString(),
|
|
username: user.username,
|
|
role: user.role,
|
|
tv: user.tokenVersion,
|
|
typ: 'refresh',
|
|
};
|
|
return this.jwt.signAsync(payload, {
|
|
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
|
});
|
|
}
|
|
|
|
private toPublic(user: {
|
|
id: bigint;
|
|
username: string;
|
|
role: string;
|
|
createdAt: Date;
|
|
}): PublicUser {
|
|
return {
|
|
id: user.id.toString(),
|
|
username: user.username,
|
|
role: user.role,
|
|
createdAt: user.createdAt.toISOString(),
|
|
};
|
|
}
|
|
}
|