207 lines
6.0 KiB
TypeScript
207 lines
6.0 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
import { Session, User } from '../../../../generated/prisma/client';
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
|
import { AuthUser, RefreshPayload } from './auth.types';
|
|
|
|
const PASSWORD_HASH_ROUNDS = 12;
|
|
const REFRESH_TTL_MS = 604_800_000;
|
|
|
|
const ACCESS_TTL = '15m';
|
|
const REFRESH_TTL = '7d';
|
|
const DUMMY_PASSWORD_HASH =
|
|
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
) {}
|
|
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
|
const normalizedEmail = this.normalizeEmail(email);
|
|
const existing = await this.prisma.user.findUnique({
|
|
where: { email: normalizedEmail },
|
|
});
|
|
if (existing)
|
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
|
|
|
let user: User;
|
|
try {
|
|
user = await this.prisma.user.create({
|
|
data: {
|
|
id: randomUUID(),
|
|
email: normalizedEmail,
|
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueConstraintError(error))
|
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
|
throw error;
|
|
}
|
|
return this.createSession(user);
|
|
}
|
|
|
|
async login(
|
|
email: string,
|
|
password: string,
|
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email: this.normalizeEmail(email) },
|
|
});
|
|
const valid = await bcrypt.compare(
|
|
password,
|
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
|
);
|
|
if (!user || !valid)
|
|
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
|
return this.createSession(user);
|
|
}
|
|
|
|
async refresh(
|
|
token: string,
|
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
|
let payload: RefreshPayload;
|
|
try {
|
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
|
algorithms: ['HS256'],
|
|
});
|
|
} catch {
|
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
}
|
|
if (payload.type !== 'refresh')
|
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
const session = await this.prisma.session.findUnique({
|
|
where: { id: payload.sid },
|
|
include: { user: true },
|
|
});
|
|
if (
|
|
!session ||
|
|
session.userId !== payload.sub ||
|
|
session.revokedAt ||
|
|
session.expiresAt <= new Date()
|
|
) {
|
|
if (session && !session.revokedAt)
|
|
await this.prisma.session.delete({ where: { id: session.id } });
|
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
}
|
|
if (session.refreshTokenHash !== this.hash(token))
|
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
return this.rotateSession(session, session.refreshTokenHash);
|
|
}
|
|
|
|
async logout(token?: string): Promise<void> {
|
|
if (!token) return;
|
|
try {
|
|
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
|
algorithms: ['HS256'],
|
|
});
|
|
if (payload.type !== 'refresh') return;
|
|
const session = await this.prisma.session.findUnique({
|
|
where: { id: payload.sid },
|
|
});
|
|
if (
|
|
session &&
|
|
!session.revokedAt &&
|
|
session.refreshTokenHash === this.hash(token)
|
|
) {
|
|
await this.prisma.session.update({
|
|
where: { id: session.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|
|
} catch {
|
|
// Logout is intentionally idempotent.
|
|
}
|
|
}
|
|
|
|
toUser(user: User): AuthUser {
|
|
return { id: user.id, email: user.email };
|
|
}
|
|
|
|
async userById(id: string): Promise<User> {
|
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
|
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
return user;
|
|
}
|
|
|
|
private async createSession(
|
|
user: User,
|
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
|
const session = await this.prisma.session.create({
|
|
data: {
|
|
id: randomUUID(),
|
|
userId: user.id,
|
|
refreshTokenHash: '',
|
|
expiresAt: new Date(),
|
|
},
|
|
});
|
|
return this.rotateSession({ ...session, user });
|
|
}
|
|
|
|
private async rotateSession(
|
|
session: Session & { user: User },
|
|
expectedRefreshTokenHash?: string,
|
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
|
const refreshToken = await this.jwt.signAsync(
|
|
{
|
|
sub: session.userId,
|
|
sid: session.id,
|
|
type: 'refresh',
|
|
jti: randomUUID(),
|
|
},
|
|
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
|
);
|
|
const data = {
|
|
refreshTokenHash: this.hash(refreshToken),
|
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
|
};
|
|
if (expectedRefreshTokenHash) {
|
|
const updated = await this.prisma.session.updateMany({
|
|
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
|
data,
|
|
});
|
|
if (updated.count !== 1)
|
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
} else {
|
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
|
}
|
|
return {
|
|
user: this.toUser(session.user),
|
|
accessToken: await this.jwt.signAsync(
|
|
{ sub: session.userId, sid: session.id, type: 'access' },
|
|
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
|
|
),
|
|
refreshToken,
|
|
};
|
|
}
|
|
|
|
private normalizeEmail(email: string): string {
|
|
return email;
|
|
}
|
|
|
|
private hash(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
}
|
|
|
|
function isUniqueConstraintError(error: unknown): boolean {
|
|
return (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'code' in error &&
|
|
(error as { code?: unknown }).code === 'P2002'
|
|
);
|
|
}
|