From 4ffab854d2318b7d57c75945df38266e5a26f188 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 22 May 2026 16:43:07 +0800 Subject: [PATCH] feat(auth): implement Strategy pattern - PasswordStrategy + OAuthStrategy abstract Co-Authored-By: Claude Opus 4.7 --- server/service/auth/strategies/index.ts | 15 ++++ server/service/auth/strategies/oauth.strategy.ts | 19 +++++ .../service/auth/strategies/password.strategy.ts | 85 ++++++++++++++++++++++ server/types/auth.ts | 45 ++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 server/service/auth/strategies/index.ts create mode 100644 server/service/auth/strategies/oauth.strategy.ts create mode 100644 server/service/auth/strategies/password.strategy.ts create mode 100644 server/types/auth.ts diff --git a/server/service/auth/strategies/index.ts b/server/service/auth/strategies/index.ts new file mode 100644 index 0000000..5a5af93 --- /dev/null +++ b/server/service/auth/strategies/index.ts @@ -0,0 +1,15 @@ +import { PasswordStrategy } from "./password.strategy"; +import { OAuthStrategy } from "./oauth.strategy"; +import type { IAuthStrategy } from "../../types/auth"; + +export { PasswordStrategy, OAuthStrategy }; + +export const strategyRegistry: Record = { + password: new PasswordStrategy(), +}; + +export function getStrategy(type: string): IAuthStrategy { + const s = strategyRegistry[type]; + if (!s) throw new Error(`Unknown auth strategy: ${type}`); + return s; +} \ No newline at end of file diff --git a/server/service/auth/strategies/oauth.strategy.ts b/server/service/auth/strategies/oauth.strategy.ts new file mode 100644 index 0000000..d2cac3c --- /dev/null +++ b/server/service/auth/strategies/oauth.strategy.ts @@ -0,0 +1,19 @@ +import type { IAuthStrategy, AuthUser, TokenPair } from "../../types/auth"; + +export abstract class OAuthStrategy implements IAuthStrategy { + abstract readonly provider: string; + abstract readonly type: "oauth"; + + abstract authenticate(oauthToken: string): Promise; + + async generateTokens( + user: AuthUser, + sessionId: string + ): Promise { + throw new Error("Use AuthService.generateTokens()"); + } + + async revokeSession(sessionId: string): Promise { + throw new Error("Use AuthService.revokeSession()"); + } +} \ No newline at end of file diff --git a/server/service/auth/strategies/password.strategy.ts b/server/service/auth/strategies/password.strategy.ts new file mode 100644 index 0000000..505eca6 --- /dev/null +++ b/server/service/auth/strategies/password.strategy.ts @@ -0,0 +1,85 @@ +import { eq } from "drizzle-orm"; +import { dbGlobal } from "@/drizzle-pkg/lib/db"; +import { users } from "@/drizzle-pkg/lib/schema/auth"; +import type { IAuthStrategy, AuthUser } from "../../types/auth"; +import { + verifyPassword, + isLocked, +} from "../lib/password"; +import { signAccessToken } from "../lib/jwt"; + +export class PasswordStrategy implements IAuthStrategy { + readonly type = "password" as const; + + async authenticate(credentials: { + email: string; + password: string; + ip?: string; + userAgent?: string; + }): Promise { + const { email, password, ip, userAgent } = credentials; + + const [user] = await dbGlobal + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user) { + throw { code: "INVALID_CREDENTIALS", message: "邮箱或密码错误" }; + } + + if (user.lockoutUntil && isLocked(user.lockoutUntil)) { + throw { code: "ACCOUNT_LOCKED", message: "账户已锁定,请稍后再试" }; + } + + const valid = await verifyPassword(password, user.password); + if (!valid) { + const attempts = (user.failedLoginAttempts || 0) + 1; + const lockoutUntil = attempts >= 5 ? new Date(Date.now() + 15 * 60_000) : null; + await dbGlobal + .update(users) + .set({ + failedLoginAttempts: attempts, + lockoutUntil: lockoutUntil ?? null, + }) + .where(eq(users.id, user.id)); + + throw { code: "INVALID_CREDENTIALS", message: "邮箱或密码错误" }; + } + + await dbGlobal + .update(users) + .set({ + failedLoginAttempts: 0, + lockoutUntil: null, + lastLoginAt: new Date(), + lastLoginIp: ip ?? null, + }) + .where(eq(users.id, user.id)); + + return { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + status: user.status, + }; + } + + async generateTokens( + user: AuthUser, + sessionId: string + ): Promise<{ accessToken: string; refreshToken: string }> { + const accessToken = await signAccessToken({ + userId: user.id, + sessionId, + role: user.role, + }); + return { accessToken, refreshToken: "" }; + } + + async revokeSession(sessionId: string): Promise { + // delegated to AuthService + } +} \ No newline at end of file diff --git a/server/types/auth.ts b/server/types/auth.ts new file mode 100644 index 0000000..2874177 --- /dev/null +++ b/server/types/auth.ts @@ -0,0 +1,45 @@ +export interface TokenPair { + accessToken: string; + refreshToken: string; +} + +export interface Session { + id: string; + userId: number; + userAgent: string | null; + ip: string | null; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; +} + +export interface AuthUser { + id: number; + email: string | null; + username: string; + role: string; + status: string; +} + +export type AuthErrorCode = + | "INVALID_CREDENTIALS" + | "ACCOUNT_LOCKED" + | "WEAK_PASSWORD" + | "EMAIL_EXISTS" + | "TOKEN_EXPIRED" + | "SESSION_REVOKED" + | "RATE_LIMITED"; + +export interface AuthError { + error: { + code: AuthErrorCode; + message: string; + }; +} + +export interface IAuthStrategy { + readonly type: "password" | "oauth"; + authenticate(credentials: unknown): Promise; + generateTokens(user: AuthUser, sessionId: string): Promise; + revokeSession(sessionId: string): Promise; +} \ No newline at end of file