Files
dsh_shenxian/src/web/routes/auth.ts
T

177 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Auth routes: self-registration (→ pending), login, logout, and the current
* identity. Registration always yields a `pending` user; an admin approves it.
* @module dshs/web/routes/auth
*/
import type { FastifyPluginAsync } from 'fastify'
import { randomUUID } from 'node:crypto'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import {
clearSessionCookie,
hashPassword,
hashSessionToken,
newSessionToken,
parseCookie,
sessionCookie,
verifyPassword,
} from '../auth.js'
const registerSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
password: { type: 'string', minLength: 8, maxLength: 128 },
},
},
} as const
const loginSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', maxLength: 64 },
password: { type: 'string', maxLength: 128 },
},
},
} as const
interface Credentials {
username: string
password: string
}
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/auth/register',
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
if ((await app.db.findUserByUsername(username)) !== undefined) {
return reply.code(409).send({ error: 'username_taken' })
}
const id = randomUUID()
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
const passHash = await hashPassword(password)
// Create the user first so `initUserRoot` resolves the DB-assigned uid
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
await app.userFs.initUserRoot(id, user.uid ?? undefined)
await app.db.audit(id, 'register', JSON.stringify({ username }))
return reply.code(201).send({ user: { id, username, role: 'pending' } })
},
)
app.post(
'/api/auth/login',
{ schema: loginSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
const user = await app.db.findUserByUsername(username)
if (user === undefined || !(await verifyPassword(password, user.pass_hash))) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
if (user.role === 'pending') return reply.code(403).send({ error: 'pending_review' })
if (user.role === 'disabled') return reply.code(403).send({ error: 'disabled' })
const token = newSessionToken()
// 单活跃会话(last-wins):新登录顶掉该账号此前所有会话——旧浏览器的
// sid 立即失效,proxy/API 对其返回 401,需重新登录后才能继续访问。
await app.db.deleteUserSessions(user.id)
await app.db.createSession({
tokenHash: hashSessionToken(token),
userId: user.id,
expiresAt: Date.now() + app.config.sessionTtlSeconds * 1000,
ip: request.ip,
userAgent: request.headers['user-agent'],
})
await app.db.audit(user.id, 'login', null)
reply.header(
'set-cookie',
sessionCookie(token, app.config.sessionTtlSeconds, app.config.secureCookies, app.config.cookieDomain),
)
return { user: toPublicUser(user) }
},
)
app.post('/api/auth/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return { ok: true }
})
// ---- 同源退出页:会话内「退出登录」整页跳转(清 sid + 删 session → 回登录页) ----
app.get('/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return reply.redirect('/')
})
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => ({ user: request.user }))
const keyAddSchema = {
body: {
type: 'object',
required: ['name', 'apiKey'],
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1, maxLength: 32 },
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
},
},
} as const
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
keys: await app.db.listCredentialKeys(request.user!.id),
}))
app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => {
const { name, apiKey } = request.body as { name: string; apiKey: string }
const cleanName = name.trim()
if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) {
return reply.code(400).send({ error: 'invalid_name' })
}
// Header-safe charset only: reject spaces, quotes, non-ASCII, etc.
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) {
return reply.code(400).send({ error: 'invalid_api_key' })
}
const key = await app.db.setCredentialKey(
request.user!.id,
cleanName,
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
)
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
// 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时
// 注入 env 的快照,不重启运行中的实例不会刷新)。
await app.supervisor.restartAllMains()
return { key }
})
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
// 切换启用的全局 key 后同样广播刷新
await app.supervisor.restartAllMains()
return { ok: true }
})
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
}