feat(api-auth): persist users and auth state in postgres
This commit is contained in:
@@ -4,6 +4,7 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { authenticator } from "@otplib/preset-default";
|
import { authenticator } from "@otplib/preset-default";
|
||||||
import { AuthMailService } from "./auth-mail.service";
|
import { AuthMailService } from "./auth-mail.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
type EmailCodeEntry = {
|
type EmailCodeEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -15,17 +16,6 @@ type AuthUser = {
|
|||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RefreshTokenEntry = {
|
|
||||||
userId: string;
|
|
||||||
expiresAt: number;
|
|
||||||
revokedAt?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TwoFactorEntry = {
|
|
||||||
secret: string;
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuthTokenResult = {
|
type AuthTokenResult = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
tokenType: "Bearer";
|
tokenType: "Bearer";
|
||||||
@@ -38,15 +28,12 @@ type AuthTokenResult = {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
|
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
|
||||||
private readonly userStoreByEmail = new Map<string, AuthUser>();
|
|
||||||
private readonly userStoreById = new Map<string, AuthUser>();
|
|
||||||
private readonly refreshTokenStore = new Map<string, RefreshTokenEntry>();
|
|
||||||
private readonly twoFactorStore = new Map<string, TwoFactorEntry>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly authMailService: AuthMailService
|
private readonly authMailService: AuthMailService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
|
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
|
||||||
@@ -83,53 +70,92 @@ export class AuthService {
|
|||||||
|
|
||||||
this.emailCodeStore.delete(lowerEmail);
|
this.emailCodeStore.delete(lowerEmail);
|
||||||
|
|
||||||
const user = this.getOrCreateUser(lowerEmail);
|
const user = await this.getOrCreateUser(lowerEmail);
|
||||||
return this.issueTokens(user);
|
return this.issueTokens(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
|
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
|
||||||
const entry = this.refreshTokenStore.get(refreshToken);
|
const entry = await this.prismaService.refreshToken.findUnique({
|
||||||
|
where: {
|
||||||
|
tokenHash: refreshToken
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new UnauthorizedException("刷新令牌不存在");
|
throw new UnauthorizedException("刷新令牌不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.revokedAt) {
|
if (entry.revokedAt) {
|
||||||
throw new UnauthorizedException("刷新令牌已注销");
|
throw new UnauthorizedException("刷新令牌已注销");
|
||||||
}
|
}
|
||||||
if (entry.expiresAt < Date.now()) {
|
|
||||||
this.refreshTokenStore.delete(refreshToken);
|
if (entry.expiresAt.getTime() < Date.now()) {
|
||||||
|
await this.prismaService.refreshToken.update({
|
||||||
|
where: {
|
||||||
|
id: entry.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revokedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
throw new UnauthorizedException("刷新令牌已过期");
|
throw new UnauthorizedException("刷新令牌已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = this.userStoreById.get(entry.userId);
|
await this.prismaService.refreshToken.update({
|
||||||
if (!user) {
|
where: {
|
||||||
throw new UnauthorizedException("用户不存在");
|
id: entry.id
|
||||||
}
|
},
|
||||||
|
data: {
|
||||||
|
revokedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
entry.revokedAt = Date.now();
|
return this.issueTokens(entry.user);
|
||||||
return this.issueTokens(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
|
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
|
||||||
const entry = this.refreshTokenStore.get(refreshToken);
|
await this.prismaService.refreshToken.updateMany({
|
||||||
if (!entry) {
|
where: {
|
||||||
return { success: true };
|
tokenHash: refreshToken,
|
||||||
}
|
revokedAt: null
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revokedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
entry.revokedAt = Date.now();
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrollTwoFactor(
|
async enrollTwoFactor(
|
||||||
email: string
|
email: string
|
||||||
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
|
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
|
||||||
const user = this.getOrCreateUser(email.toLowerCase());
|
const user = await this.getOrCreateUser(email.toLowerCase());
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
|
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
|
||||||
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
|
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
|
||||||
|
|
||||||
this.twoFactorStore.set(user.id, {
|
await this.prismaService.userSecurity.upsert({
|
||||||
secret,
|
where: {
|
||||||
enabled: false
|
userId: user.id
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
twoFactorSecret: secret,
|
||||||
|
twoFactorEnabled: false
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
twoFactorSecret: secret,
|
||||||
|
twoFactorEnabled: false
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -144,38 +170,54 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
token: string
|
token: string
|
||||||
): Promise<{ success: boolean; enabled: boolean }> {
|
): Promise<{ success: boolean; enabled: boolean }> {
|
||||||
const user = this.getOrCreateUser(email.toLowerCase());
|
const user = await this.getOrCreateUser(email.toLowerCase());
|
||||||
const entry = this.twoFactorStore.get(user.id);
|
const security = await this.prismaService.userSecurity.findUnique({
|
||||||
if (!entry) {
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
twoFactorSecret: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!security?.twoFactorSecret) {
|
||||||
throw new UnauthorizedException("尚未启用两步验证");
|
throw new UnauthorizedException("尚未启用两步验证");
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = authenticator.check(token, entry.secret);
|
const valid = authenticator.check(token, security.twoFactorSecret);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new UnauthorizedException("两步验证码错误");
|
throw new UnauthorizedException("两步验证码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.enabled = true;
|
await this.prismaService.userSecurity.update({
|
||||||
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
enabled: true
|
enabled: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateUser(email: string): AuthUser {
|
private async getOrCreateUser(email: string): Promise<AuthUser> {
|
||||||
const existingUser = this.userStoreByEmail.get(email);
|
return this.prismaService.user.upsert({
|
||||||
if (existingUser) {
|
where: {
|
||||||
return existingUser;
|
email
|
||||||
}
|
},
|
||||||
|
update: {},
|
||||||
const newUser = {
|
create: {
|
||||||
id: randomUUID(),
|
email
|
||||||
email
|
},
|
||||||
};
|
select: {
|
||||||
this.userStoreByEmail.set(email, newUser);
|
id: true,
|
||||||
this.userStoreById.set(newUser.id, newUser);
|
email: true
|
||||||
|
}
|
||||||
return newUser;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCode(): string {
|
private generateCode(): string {
|
||||||
@@ -195,9 +237,12 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
const refreshToken = `${randomUUID()}${randomUUID()}`;
|
const refreshToken = `${randomUUID()}${randomUUID()}`;
|
||||||
|
|
||||||
this.refreshTokenStore.set(refreshToken, {
|
await this.prismaService.refreshToken.create({
|
||||||
userId: user.id,
|
data: {
|
||||||
expiresAt: Date.now() + refreshExpiresInSeconds * 1000
|
userId: user.id,
|
||||||
|
tokenHash: refreshToken,
|
||||||
|
expiresAt: new Date(Date.now() + refreshExpiresInSeconds * 1000)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user