You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

200 lines
5.3 KiB

import { v4 as uuidv4 } from "uuid";
import { eq, isNull } from "drizzle-orm";
import { dbGlobal } from "@/drizzle-pkg/lib/db";
import { users, userSessions } from "@/drizzle-pkg/lib/schema/auth";
import { signAccessToken } from "./lib/jwt";
import { hashPassword, validatePasswordStrength } from "./lib/password";
import type { AuthUser, TokenPair } from "../../types/auth";
import { getStrategy } from "./strategies";
const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export class AuthService {
async register(opts: {
email: string;
password: string;
username: string;
ip?: string;
userAgent?: string;
}): Promise<AuthUser> {
const { email, password, username, ip, userAgent } = opts;
// 检查邮箱是否已存在
const [existing] = await dbGlobal
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing) {
throw { code: "EMAIL_EXISTS", message: "该邮箱已注册" };
}
// 密码强度校验
const { valid, errors } = validatePasswordStrength(password);
if (!valid) {
throw { code: "WEAK_PASSWORD", message: errors.join(";") };
}
const passwordHash = await hashPassword(password);
// 插入用户
const [user] = await dbGlobal
.insert(users)
.values({
email,
username,
password: passwordHash,
role: "user",
status: "active",
passwordHistory: JSON.stringify([passwordHash]),
})
.returning();
// 创建 session
await this.createSession(user.id, ip, userAgent);
return {
id: user.id,
email: user.email ?? null,
username: user.username,
role: user.role,
status: user.status,
};
}
async login(opts: {
email: string;
password: string;
ip?: string;
userAgent?: string;
}): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const { email, password, ip, userAgent } = opts;
// 用 PasswordStrategy 认证
const strategy = getStrategy("password");
const user = await strategy.authenticate({ email, password, ip, userAgent });
// 创建 session
const sessionId = await this.createSession(user.id, ip, userAgent);
// 生成 tokens
const tokens = await this.generateTokens(user, sessionId);
return { user, ...tokens };
}
async logout(sessionId: string): Promise<void> {
await dbGlobal
.update(userSessions)
.set({ revokedAt: new Date() })
.where(eq(userSessions.id, sessionId));
}
async refreshToken(
refreshToken: string
): Promise<{ accessToken: string; newRefreshToken: string }> {
// 查找有效 session
const sessions = await dbGlobal
.select()
.from(userSessions)
.where(isNull(userSessions.revokedAt));
let matchedSession: (typeof sessions)[0] | null = null;
for (const s of sessions) {
if (s.id === refreshToken && s.expiresAt > new Date()) {
matchedSession = s;
break;
}
}
if (!matchedSession) {
throw { code: "SESSION_REVOKED", message: "Session 已失效" };
}
const [user] = await dbGlobal
.select()
.from(users)
.where(eq(users.id, matchedSession.userId))
.limit(1);
if (!user || user.status !== "active") {
throw { code: "INVALID_CREDENTIALS", message: "用户状态异常" };
}
// 作废旧 session
await dbGlobal
.update(userSessions)
.set({ revokedAt: new Date() })
.where(eq(userSessions.id, matchedSession.id));
// 创建新 session (refresh token rotation)
const newSessionId = await this.createSession(
user.id,
matchedSession.ip ?? undefined,
matchedSession.userAgent ?? undefined
);
const authUser: AuthUser = {
id: user.id,
email: user.email ?? null,
username: user.username,
role: user.role,
status: user.status,
};
const tokens = await this.generateTokens(authUser, newSessionId);
return { accessToken: tokens.accessToken, newRefreshToken: tokens.refreshToken };
}
async revokeSession(sessionId: string): Promise<void> {
await dbGlobal
.update(userSessions)
.set({ revokedAt: new Date() })
.where(eq(userSessions.id, sessionId));
}
private async createSession(
userId: number,
ip?: string,
userAgent?: string
): Promise<string> {
const id = uuidv4();
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS);
await dbGlobal.insert(userSessions).values({
id,
userId,
refreshTokenHash: "", // filled by generateTokens
userAgent: userAgent ?? null,
ip: ip ?? null,
createdAt: new Date(),
expiresAt,
revokedAt: null,
});
return id;
}
private async generateTokens(
user: AuthUser,
sessionId: string
): Promise<TokenPair> {
const accessToken = await signAccessToken({
userId: user.id,
sessionId,
role: user.role,
});
// refreshToken = sessionId itself (UUIDv4 is sufficiently random)
const refreshToken = sessionId;
await dbGlobal
.update(userSessions)
.set({ refreshTokenHash: refreshToken })
.where(eq(userSessions.id, sessionId));
return { accessToken, refreshToken };
}
}
export const authService = new AuthService();