4 changed files with 164 additions and 0 deletions
@ -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<string, IAuthStrategy> = { |
||||
|
password: new PasswordStrategy(), |
||||
|
}; |
||||
|
|
||||
|
export function getStrategy(type: string): IAuthStrategy { |
||||
|
const s = strategyRegistry[type]; |
||||
|
if (!s) throw new Error(`Unknown auth strategy: ${type}`); |
||||
|
return s; |
||||
|
} |
||||
@ -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<AuthUser>; |
||||
|
|
||||
|
async generateTokens( |
||||
|
user: AuthUser, |
||||
|
sessionId: string |
||||
|
): Promise<TokenPair> { |
||||
|
throw new Error("Use AuthService.generateTokens()"); |
||||
|
} |
||||
|
|
||||
|
async revokeSession(sessionId: string): Promise<void> { |
||||
|
throw new Error("Use AuthService.revokeSession()"); |
||||
|
} |
||||
|
} |
||||
@ -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<AuthUser> { |
||||
|
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<void> { |
||||
|
// delegated to AuthService
|
||||
|
} |
||||
|
} |
||||
@ -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<AuthUser>; |
||||
|
generateTokens(user: AuthUser, sessionId: string): Promise<TokenPair>; |
||||
|
revokeSession(sessionId: string): Promise<void>; |
||||
|
} |
||||
Loading…
Reference in new issue