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

176 lines
7.0 KiB
TypeScript
Raw Normal View History

/**
* 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 }
})
}