162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { getHeader } from 'h3'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { authSessions, db, eq } from '~/drizzle/db'
|
|
import { and, isNull, ne } from 'drizzle-orm'
|
|
import type { H3Event } from 'h3'
|
|
import { getClientIP } from '~~/server/utils/ip-utils'
|
|
import { getServerDate } from '~~/server/utils/serverTime'
|
|
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
|
import { createApiError } from '~~/server/utils/apiError'
|
|
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
|
|
|
export const AUTH_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
|
|
|
function getSessionExpiry() {
|
|
const expiresAt = getServerDate()
|
|
expiresAt.setTime(expiresAt.getTime() + AUTH_SESSION_MAX_AGE_SECONDS * 1000)
|
|
return expiresAt
|
|
}
|
|
|
|
function getDateFromTimestamp(timestamp: number) {
|
|
const date = getServerDate()
|
|
date.setTime(timestamp)
|
|
return date
|
|
}
|
|
|
|
type SessionUser = { id: number; role: string; tokenVersion?: number }
|
|
|
|
/**
|
|
* 判断登录会话存储是否因迁移缺失而不可用。
|
|
* 会话表不可用时禁止签发或接受无法撤销的登录令牌。
|
|
*/
|
|
export function isAuthSessionStorageError(error: unknown) {
|
|
const value = error as { code?: string; message?: string; cause?: { code?: string; message?: string } } | null
|
|
const code = String(value?.code || value?.cause?.code || '')
|
|
const message = String(value?.message || value?.cause?.message || error || '')
|
|
return ['42P01', '42703'].includes(code) && /auth_sessions/i.test(message)
|
|
}
|
|
|
|
function readUserAgent(event: H3Event) {
|
|
return (getHeader(event, 'user-agent') || '').slice(0, 500)
|
|
}
|
|
|
|
export function parseUserAgent(userAgent: string) {
|
|
const browser = /Edg\/?([\d.]+)/i.test(userAgent)
|
|
? 'Edge'
|
|
: /OPR\/?([\d.]+)/i.test(userAgent)
|
|
? 'Opera'
|
|
: /Chrome\/?([\d.]+)/i.test(userAgent)
|
|
? 'Chrome'
|
|
: /Firefox\/?([\d.]+)/i.test(userAgent)
|
|
? 'Firefox'
|
|
: /Safari\/?([\d.]+)/i.test(userAgent)
|
|
? 'Safari'
|
|
: 'Unknown browser'
|
|
const operatingSystem = /Windows NT/i.test(userAgent)
|
|
? 'Windows'
|
|
: /Mac OS X/i.test(userAgent)
|
|
? 'macOS'
|
|
: /Android/i.test(userAgent)
|
|
? 'Android'
|
|
: /iPhone|iPad|iPod/i.test(userAgent)
|
|
? 'iOS'
|
|
: /Linux/i.test(userAgent)
|
|
? 'Linux'
|
|
: 'Unknown OS'
|
|
const device = /iPad|Tablet/i.test(userAgent)
|
|
? 'Tablet'
|
|
: /Mobile|iPhone|Android/i.test(userAgent)
|
|
? 'Mobile'
|
|
: 'Desktop'
|
|
|
|
return { browser, operatingSystem, device }
|
|
}
|
|
|
|
export async function createAuthSession(event: H3Event, user: SessionUser, loginMethod = 'password') {
|
|
const id = randomUUID()
|
|
const now = getServerDate()
|
|
const userAgent = readUserAgent(event)
|
|
const metadata = parseUserAgent(userAgent)
|
|
|
|
try {
|
|
await db.insert(authSessions).values({
|
|
id,
|
|
userId: user.id,
|
|
createdAt: now,
|
|
lastActiveAt: now,
|
|
expiresAt: getSessionExpiry(),
|
|
ipAddress: getClientIP(event),
|
|
userAgent,
|
|
...metadata,
|
|
loginMethod
|
|
})
|
|
} catch (error) {
|
|
if (!isAuthSessionStorageError(error)) throw error
|
|
console.error('[Auth] auth_sessions 表不可用,拒绝签发无法撤销的登录令牌:', error)
|
|
throw createApiError(
|
|
503,
|
|
SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE,
|
|
'登录会话存储暂时不可用,请先完成数据库迁移'
|
|
)
|
|
}
|
|
|
|
return {
|
|
id,
|
|
token: JWTEnhanced.generateToken(user.id, user.role, user.tokenVersion ?? 0, id)
|
|
}
|
|
}
|
|
|
|
export async function ensureAuthSession(event: H3Event, payload: { userId: number; jti: string; exp?: number }) {
|
|
const existing = await db.query.authSessions.findFirst({ where: eq(authSessions.id, payload.jti) })
|
|
if (existing) return existing
|
|
|
|
const now = getServerDate()
|
|
const userAgent = readUserAgent(event)
|
|
const metadata = parseUserAgent(userAgent)
|
|
const expiresAt = payload.exp ? getDateFromTimestamp(payload.exp * 1000) : getSessionExpiry()
|
|
await db.insert(authSessions).values({
|
|
id: payload.jti,
|
|
userId: payload.userId,
|
|
createdAt: now,
|
|
lastActiveAt: now,
|
|
expiresAt,
|
|
ipAddress: getClientIP(event),
|
|
userAgent,
|
|
...metadata,
|
|
loginMethod: 'legacy'
|
|
}).onConflictDoNothing()
|
|
return db.query.authSessions.findFirst({ where: eq(authSessions.id, payload.jti) })
|
|
}
|
|
|
|
export async function touchAuthSession(sessionId: string, extendExpiry = false) {
|
|
const update = {
|
|
lastActiveAt: getServerDate(),
|
|
...(extendExpiry ? { expiresAt: getSessionExpiry() } : {})
|
|
}
|
|
await db.update(authSessions)
|
|
.set(update)
|
|
.where(and(eq(authSessions.id, sessionId), isNull(authSessions.revokedAt)))
|
|
}
|
|
|
|
export async function revokeAuthSession(userId: number, sessionId: string, reason = 'user') {
|
|
const result = await db.update(authSessions)
|
|
.set({ revokedAt: getServerDate(), revokedReason: reason })
|
|
.where(and(eq(authSessions.id, sessionId), eq(authSessions.userId, userId), isNull(authSessions.revokedAt)))
|
|
.returning({ id: authSessions.id })
|
|
return result.length > 0
|
|
}
|
|
|
|
export async function revokeOtherAuthSessions(userId: number, currentSessionId: string | null, reason = 'logout_others') {
|
|
const conditions = [eq(authSessions.userId, userId), isNull(authSessions.revokedAt)]
|
|
if (currentSessionId) conditions.push(ne(authSessions.id, currentSessionId))
|
|
const result = await db.update(authSessions)
|
|
.set({ revokedAt: getServerDate(), revokedReason: reason })
|
|
.where(and(...conditions))
|
|
.returning({ id: authSessions.id })
|
|
return result.length
|
|
}
|
|
|
|
export function sessionExpiryIsActive(expiresAt: Date) {
|
|
return expiresAt.getTime() > getServerDate().getTime()
|
|
}
|