258 lines
6.4 KiB
TypeScript
258 lines
6.4 KiB
TypeScript
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { JwtService } from "@nestjs/jwt";
|
|
import { randomUUID } from "node:crypto";
|
|
import { authenticator } from "@otplib/preset-default";
|
|
import { AuthMailService } from "./auth-mail.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
|
|
type EmailCodeEntry = {
|
|
code: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
type AuthUser = {
|
|
id: string;
|
|
email: string;
|
|
};
|
|
|
|
type AuthTokenResult = {
|
|
accessToken: string;
|
|
tokenType: "Bearer";
|
|
expiresInSeconds: number;
|
|
refreshToken: string;
|
|
refreshExpiresInSeconds: number;
|
|
user: AuthUser;
|
|
};
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
|
|
|
|
constructor(
|
|
private readonly configService: ConfigService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly authMailService: AuthMailService,
|
|
private readonly prismaService: PrismaService
|
|
) {}
|
|
|
|
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
|
|
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
|
|
const code = this.generateCode();
|
|
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
const normalizedEmail = email.toLowerCase();
|
|
|
|
await this.authMailService.sendLoginCode(normalizedEmail, code, ttlSeconds);
|
|
this.emailCodeStore.set(normalizedEmail, { code, expiresAt });
|
|
|
|
return {
|
|
success: true,
|
|
expiresInSeconds: ttlSeconds
|
|
};
|
|
}
|
|
|
|
async loginWithEmailCode(email: string, code: string): Promise<AuthTokenResult> {
|
|
const lowerEmail = email.toLowerCase();
|
|
const codeEntry = this.emailCodeStore.get(lowerEmail);
|
|
|
|
if (!codeEntry) {
|
|
throw new UnauthorizedException("验证码不存在或已失效");
|
|
}
|
|
|
|
if (codeEntry.expiresAt < Date.now()) {
|
|
this.emailCodeStore.delete(lowerEmail);
|
|
throw new UnauthorizedException("验证码已过期");
|
|
}
|
|
|
|
if (codeEntry.code !== code) {
|
|
throw new UnauthorizedException("验证码错误");
|
|
}
|
|
|
|
this.emailCodeStore.delete(lowerEmail);
|
|
|
|
const user = await this.getOrCreateUser(lowerEmail);
|
|
return this.issueTokens(user);
|
|
}
|
|
|
|
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
|
|
const entry = await this.prismaService.refreshToken.findUnique({
|
|
where: {
|
|
tokenHash: refreshToken
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
email: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new UnauthorizedException("刷新令牌不存在");
|
|
}
|
|
|
|
if (entry.revokedAt) {
|
|
throw new UnauthorizedException("刷新令牌已注销");
|
|
}
|
|
|
|
if (entry.expiresAt.getTime() < Date.now()) {
|
|
await this.prismaService.refreshToken.update({
|
|
where: {
|
|
id: entry.id
|
|
},
|
|
data: {
|
|
revokedAt: new Date()
|
|
}
|
|
});
|
|
throw new UnauthorizedException("刷新令牌已过期");
|
|
}
|
|
|
|
await this.prismaService.refreshToken.update({
|
|
where: {
|
|
id: entry.id
|
|
},
|
|
data: {
|
|
revokedAt: new Date()
|
|
}
|
|
});
|
|
|
|
return this.issueTokens(entry.user);
|
|
}
|
|
|
|
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
|
|
await this.prismaService.refreshToken.updateMany({
|
|
where: {
|
|
tokenHash: refreshToken,
|
|
revokedAt: null
|
|
},
|
|
data: {
|
|
revokedAt: new Date()
|
|
}
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
async enrollTwoFactor(
|
|
email: string
|
|
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
|
|
const user = await this.getOrCreateUser(email.toLowerCase());
|
|
const secret = authenticator.generateSecret();
|
|
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
|
|
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
|
|
|
|
await this.prismaService.userSecurity.upsert({
|
|
where: {
|
|
userId: user.id
|
|
},
|
|
update: {
|
|
twoFactorSecret: secret,
|
|
twoFactorEnabled: false
|
|
},
|
|
create: {
|
|
userId: user.id,
|
|
twoFactorSecret: secret,
|
|
twoFactorEnabled: false
|
|
}
|
|
});
|
|
|
|
return {
|
|
userId: user.id,
|
|
secret,
|
|
otpauthUrl,
|
|
enabled: false
|
|
};
|
|
}
|
|
|
|
async verifyTwoFactor(
|
|
email: string,
|
|
token: string
|
|
): Promise<{ success: boolean; enabled: boolean }> {
|
|
const user = await this.getOrCreateUser(email.toLowerCase());
|
|
const security = await this.prismaService.userSecurity.findUnique({
|
|
where: {
|
|
userId: user.id
|
|
},
|
|
select: {
|
|
twoFactorSecret: true
|
|
}
|
|
});
|
|
|
|
if (!security?.twoFactorSecret) {
|
|
throw new UnauthorizedException("尚未启用两步验证");
|
|
}
|
|
|
|
const valid = authenticator.check(token, security.twoFactorSecret);
|
|
if (!valid) {
|
|
throw new UnauthorizedException("两步验证码错误");
|
|
}
|
|
|
|
await this.prismaService.userSecurity.update({
|
|
where: {
|
|
userId: user.id
|
|
},
|
|
data: {
|
|
twoFactorEnabled: true
|
|
}
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
enabled: true
|
|
};
|
|
}
|
|
|
|
private async getOrCreateUser(email: string): Promise<AuthUser> {
|
|
return this.prismaService.user.upsert({
|
|
where: {
|
|
email
|
|
},
|
|
update: {},
|
|
create: {
|
|
email
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true
|
|
}
|
|
});
|
|
}
|
|
|
|
private generateCode(): string {
|
|
return String(Math.floor(100000 + Math.random() * 900000));
|
|
}
|
|
|
|
private async issueTokens(user: AuthUser): Promise<AuthTokenResult> {
|
|
const accessExpiresInSeconds = Number(
|
|
this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900
|
|
);
|
|
const refreshExpiresInSeconds = Number(
|
|
this.configService.get("AUTH_REFRESH_EXPIRES_IN_SECONDS") ?? 2592000
|
|
);
|
|
const accessToken = await this.jwtService.signAsync({
|
|
sub: user.id,
|
|
email: user.email
|
|
});
|
|
const refreshToken = `${randomUUID()}${randomUUID()}`;
|
|
|
|
await this.prismaService.refreshToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
tokenHash: refreshToken,
|
|
expiresAt: new Date(Date.now() + refreshExpiresInSeconds * 1000)
|
|
}
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
tokenType: "Bearer",
|
|
expiresInSeconds: accessExpiresInSeconds,
|
|
refreshToken,
|
|
refreshExpiresInSeconds,
|
|
user
|
|
};
|
|
}
|
|
}
|