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.
195 lines
5.1 KiB
195 lines
5.1 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: "[]",
|
|
})
|
|
.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 [session] = await dbGlobal
|
|
.select()
|
|
.from(userSessions)
|
|
.where(eq(userSessions.id, refreshToken))
|
|
.limit(1);
|
|
|
|
if (!session || session.revokedAt !== null || session.expiresAt <= new Date()) {
|
|
throw { code: "SESSION_REVOKED", message: "Session 已失效" };
|
|
}
|
|
|
|
const matchedSession = 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();
|