Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
+215
View File
@@ -0,0 +1,215 @@
import type { Context, Next } from 'koa'
import { createHmac, timingSafeEqual } from 'crypto'
import { getToken } from '../services/auth'
import {
findUserById,
touchUserLogin,
userCanAccessProfile,
type UserRecord,
type UserRole,
} from '../db/hermes/users-store'
export interface AuthenticatedUser {
id: number
username: string
role: UserRole
}
export interface RequestProfile {
name: string
}
interface JwtPayload {
sub: string
username: string
role: UserRole
type: 'access'
aud: 'hermes-web-ui'
iat: number
exp: number
}
declare module 'koa' {
interface DefaultState {
user?: AuthenticatedUser
profile?: RequestProfile
}
}
const JWT_AUDIENCE = 'hermes-web-ui'
const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30
function base64UrlJson(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
function sign(input: string, secret: string): string {
return createHmac('sha256', secret).update(input).digest('base64url')
}
function safeEqual(a: string, b: string): boolean {
try {
const left = Buffer.from(a)
const right = Buffer.from(b)
return left.length === right.length && timingSafeEqual(left, right)
} catch {
return false
}
}
async function getJwtSecret(): Promise<string | null> {
return process.env.AUTH_JWT_SECRET || await getToken()
}
function requestToken(ctx: Context): string {
const auth = ctx.headers.authorization || ''
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim()
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
}
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
const iat = Math.floor(now / 1000)
const payload: JwtPayload = {
sub: String(user.id),
username: user.username,
role: user.role,
type: 'access',
aud: JWT_AUDIENCE,
iat,
exp: iat + DEFAULT_EXPIRES_SECONDS,
}
const header = base64UrlJson({ alg: 'HS256', typ: 'JWT' })
const body = base64UrlJson(payload)
const unsigned = `${header}.${body}`
return `${unsigned}.${sign(unsigned, secret)}`
}
export function verifyUserJwt(token: string, secret: string, now = Date.now()): JwtPayload | null {
const parts = token.split('.')
if (parts.length !== 3) return null
const [header, body, signature] = parts
const expected = sign(`${header}.${body}`, secret)
if (!safeEqual(signature, expected)) return null
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial<JwtPayload>
if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null
if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null
if (Math.floor(now / 1000) >= payload.exp) return null
return payload as JwtPayload
} catch {
return null
}
}
export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>): Promise<string> {
const secret = await getJwtSecret()
if (!secret) throw new Error('Auth is disabled on this server')
return signUserJwt(user, secret)
}
export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser {
return {
id: user.id,
username: user.username,
role: user.role,
}
}
export async function authenticateUserToken(token: string): Promise<AuthenticatedUser | null> {
const secret = await getJwtSecret()
if (!secret) return null
const payload = token ? verifyUserJwt(token, secret) : null
if (!payload) return null
const user = findUserById(payload.sub)
if (!user || user.status !== 'active') return null
return toAuthenticatedUser(user)
}
export async function isAuthEnabled(): Promise<boolean> {
return !!await getJwtSecret()
}
export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
const secret = await getJwtSecret()
if (!secret) {
await next()
return
}
const token = requestToken(ctx)
const payload = token ? verifyUserJwt(token, secret) : null
if (!payload) {
ctx.status = 401
ctx.body = { error: 'Unauthorized' }
return
}
const user = findUserById(payload.sub)
if (!user || user.status !== 'active') {
ctx.status = 403
ctx.body = { error: 'User is disabled or does not exist' }
return
}
ctx.state.user = toAuthenticatedUser(user)
touchUserLogin(user.id)
await next()
}
export async function requireSuperAdmin(ctx: Context, next: Next): Promise<void> {
if (ctx.state.user?.role !== 'super_admin') {
ctx.status = 403
ctx.body = { error: 'Super administrator privileges are required' }
return
}
await next()
}
export function resolveRequestedProfile(ctx: Context): string {
if (ctx.path === '/api/hermes/available-models' && typeof ctx.query.profile !== 'string') {
return ''
}
const headerProfile = ctx.get('x-hermes-profile')
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
const body = ctx.request.body as { profile?: unknown } | undefined
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
return (headerProfile || queryProfile || bodyProfile || '').trim()
}
export async function resolveUserProfile(ctx: Context, next: Next): Promise<void> {
const user = ctx.state.user
if (!user) {
await next()
return
}
const profileName = resolveRequestedProfile(ctx)
if (!profileName) {
await next()
return
}
if (user.role !== 'super_admin' && !userCanAccessProfile(user.id, profileName)) {
ctx.status = 403
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
return
}
ctx.state.profile = { name: profileName }
await next()
}
export async function requireUserProfile(ctx: Context, next: Next): Promise<void> {
if (!ctx.state.profile?.name) {
ctx.status = 400
ctx.body = { error: 'Profile is required' }
return
}
await next()
}
export const userAuthMiddleware = [requireUserJwt, resolveUserProfile]