import { randomUUID } from 'node:crypto'; import { BadRequestException, Injectable, InternalServerErrorException, UnauthorizedException, } from '@nestjs/common'; import { hash, verify } from '@node-rs/argon2'; import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; import type { User } from '@prisma/client'; import { nanoid } from 'nanoid'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; import { verifyChallengeResponse } from '../../storage'; import { Quota_FreePlanV1 } from '../quota'; import { MailService } from './mailer'; export type UserClaim = Pick< User, 'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl' > & { hasPassword?: boolean; }; export const getUtcTimestamp = () => Math.floor(Date.now() / 1000); @Injectable() export class AuthService { constructor( private readonly config: Config, private readonly prisma: PrismaService, private readonly mailer: MailService ) {} sign(user: UserClaim) { const now = getUtcTimestamp(); return sign( { data: { id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified?.toISOString(), image: user.avatarUrl, hasPassword: Boolean(user.hasPassword), createdAt: user.createdAt.toISOString(), }, iat: now, exp: now + this.config.auth.accessTokenExpiresIn, iss: this.config.serverId, sub: user.id, aud: user.name, jti: randomUUID({ disableEntropyCache: true, }), }, this.config.auth.privateKey, { algorithm: Algorithm.ES256, } ); } refresh(user: UserClaim) { const now = getUtcTimestamp(); return sign( { data: { id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified?.toISOString(), image: user.avatarUrl, hasPassword: Boolean(user.hasPassword), createdAt: user.createdAt.toISOString(), }, exp: now + this.config.auth.refreshTokenExpiresIn, iat: now, iss: this.config.serverId, sub: user.id, aud: user.name, jti: randomUUID({ disableEntropyCache: true, }), }, this.config.auth.privateKey, { algorithm: Algorithm.ES256, } ); } async verify(token: string) { try { const data = ( await jwtVerify(token, this.config.auth.publicKey, { algorithms: [Algorithm.ES256], iss: [this.config.serverId], leeway: this.config.auth.leeway, requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'], }) ).data as UserClaim; return { ...data, emailVerified: data.emailVerified ? new Date(data.emailVerified) : null, createdAt: new Date(data.createdAt), }; } catch (e) { throw new UnauthorizedException('Invalid token'); } } async verifyCaptchaToken(token: any, ip: string) { if (typeof token !== 'string' || !token) return false; const formData = new FormData(); formData.append('secret', this.config.auth.captcha.turnstile.secret); formData.append('response', token); formData.append('remoteip', ip); // prevent replay attack formData.append('idempotency_key', nanoid()); const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; const result = await fetch(url, { body: formData, method: 'POST', }); const outcome = await result.json(); return ( !!outcome.success && // skip hostname check in dev mode (this.config.affineEnv === 'dev' || outcome.hostname === this.config.host) ); } async verifyChallengeResponse(response: any, resource: string) { return verifyChallengeResponse( response, this.config.auth.captcha.challenge.bits, resource ); } async signIn(email: string, password: string): Promise { const user = await this.prisma.user.findFirst({ where: { email, }, }); if (!user) { throw new BadRequestException('Invalid email'); } if (!user.password) { throw new BadRequestException('User has no password'); } let equal = false; try { equal = await verify(user.password, password); } catch (e) { console.error(e); throw new InternalServerErrorException(e, 'Verify password failed'); } if (!equal) { throw new UnauthorizedException('Invalid password'); } return user; } async signUp(name: string, email: string, password: string): Promise { const user = await this.prisma.user.findFirst({ where: { email, }, }); if (user) { throw new BadRequestException('Email already exists'); } const hashedPassword = await hash(password); return this.prisma.user.create({ data: { name, email, password: hashedPassword, features: { create: { reason: 'created by api sign up', activated: true, feature: { connect: { feature_version: Quota_FreePlanV1, }, }, }, }, }, }); } async createAnonymousUser(email: string): Promise { const user = await this.prisma.user.findFirst({ where: { email, }, }); if (user) { throw new BadRequestException('Email already exists'); } return this.prisma.user.create({ data: { name: 'Unnamed', email, features: { create: { reason: 'created by invite sign up', activated: true, feature: { connect: { feature_version: Quota_FreePlanV1, }, }, }, }, }, }); } async getUserByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { email, }, }); } async isUserHasPassword(email: string): Promise { const user = await this.prisma.user.findFirst({ where: { email, }, }); if (!user) { throw new BadRequestException('Invalid email'); } return Boolean(user.password); } async changePassword(email: string, newPassword: string): Promise { const user = await this.prisma.user.findUnique({ where: { email, emailVerified: { not: null, }, }, }); if (!user) { throw new BadRequestException('Invalid email'); } const hashedPassword = await hash(newPassword); return this.prisma.user.update({ where: { id: user.id, }, data: { password: hashedPassword, }, }); } async changeEmail(id: string, newEmail: string): Promise { const user = await this.prisma.user.findUnique({ where: { id, }, }); if (!user) { throw new BadRequestException('Invalid email'); } return this.prisma.user.update({ where: { id, }, data: { email: newEmail, }, }); } async sendChangePasswordEmail(email: string, callbackUrl: string) { return this.mailer.sendChangePasswordEmail(email, callbackUrl); } async sendSetPasswordEmail(email: string, callbackUrl: string) { return this.mailer.sendSetPasswordEmail(email, callbackUrl); } async sendChangeEmail(email: string, callbackUrl: string) { return this.mailer.sendChangeEmail(email, callbackUrl); } async sendVerifyChangeEmail(email: string, callbackUrl: string) { return this.mailer.sendVerifyChangeEmail(email, callbackUrl); } async sendNotificationChangeEmail(email: string) { return this.mailer.sendNotificationChangeEmail(email); } }