feat(auth): support multiple root admin emails and enhance admin login verification
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled

- Updated the environment variable configuration to allow multiple root admin emails via `ADMIN_EMAILS`, while maintaining compatibility with the legacy `ADMIN_EMAIL`.
- Modified the admin login verification process to check against the list of root admin emails.
- Enhanced the admin role management in the frontend to reflect the new multiple admin structure.
This commit is contained in:
hjjjj 2026-03-27 16:44:01 +08:00
parent 1778badacf
commit 7bb3ef8d09
4 changed files with 74 additions and 38 deletions

View File

@ -4,6 +4,8 @@ DB_NAME=skills_market
JWT_SECRET=your-jwt-secret-key-change-in-production JWT_SECRET=your-jwt-secret-key-change-in-production
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
# Root admins (comma-separated). Legacy ADMIN_EMAIL is still supported.
ADMIN_EMAILS=admin@example.com,ops@example.com
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
# 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔 # 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔

View File

@ -61,7 +61,7 @@ npm run dev
- 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login` - 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login`
- 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式) - 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式)
- 相关接口:`/api/admin/login``/api/admin/users``/api/admin/users/:userId/permissions``/api/admin/audit-logs` - 相关接口:`/api/admin/login``/api/admin/users``/api/admin/users/:userId/permissions``/api/admin/audit-logs`
- 权限规则:`ADMIN_EMAIL` 为初始管理员root admin);其他管理员只能编辑非 admin 用户,且不能修改自己的权限 - 权限规则:`ADMIN_EMAILS`逗号分隔定义初始管理员root admin兼容旧的 `ADMIN_EMAIL`);其他管理员只能编辑非 admin 用户,且不能修改自己的权限
## 环境变量 ## 环境变量

View File

@ -482,6 +482,7 @@
document.getElementById('userCount').textContent = String(users.length || 0) document.getElementById('userCount').textContent = String(users.length || 0)
document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length) document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length)
const grantable = adminCapabilities.grantable_permissions || {} const grantable = adminCapabilities.grantable_permissions || {}
const canEditAdmins = !!adminCapabilities.canEditAdmins
const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : [] const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : []
tbody.innerHTML = users tbody.innerHTML = users
.map((u) => { .map((u) => {
@ -495,6 +496,16 @@
) )
.join(' ') .join(' ')
const editable = !!u.editable const editable = !!u.editable
const roleOptions = [
`<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>`,
...(u.role === 'admin' || canEditAdmins
? [`<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>`]
: [])
].join('')
const roleHint =
!canEditAdmins && u.role !== 'admin'
? '<div class="muted">仅初始管理员可授予 admin</div>'
: ''
return ` return `
<tr data-id="${u.id}"> <tr data-id="${u.id}">
@ -505,9 +516,9 @@
<td> <td>
<div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div> <div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div>
<select data-field="role" ${editable ? '' : 'disabled'}> <select data-field="role" ${editable ? '' : 'disabled'}>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option> ${roleOptions}
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
</select> </select>
${roleHint}
</td> </td>
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td> <td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td> <td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
@ -559,7 +570,7 @@
} }
const data = await api('/api/auth/send-code', { const data = await api('/api/auth/send-code', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }) body: JSON.stringify({ email, adminOnly: true })
}) })
if (!data.success) { if (!data.success) {
notify(data.error || '发送验证码失败', 'error') notify(data.error || '发送验证码失败', 'error')

View File

@ -4,7 +4,17 @@ const { sendVerificationCode, verifyCode } = require('../services/auth')
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production' const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d' const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
const ROOT_ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase() const ROOT_ADMIN_EMAILS = Array.from(
new Set(
[
...(process.env.ADMIN_EMAILS || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean),
(process.env.ADMIN_EMAIL || '').trim().toLowerCase()
].filter(Boolean)
)
)
const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888' const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888'
const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '') const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '')
@ -54,7 +64,8 @@ function normalizeRole(role) {
} }
function isRootAdminEmail(email) { function isRootAdminEmail(email) {
return !!ROOT_ADMIN_EMAIL && String(email || '').toLowerCase() === ROOT_ADMIN_EMAIL const emailLower = String(email || '').toLowerCase()
return emailLower.length > 0 && ROOT_ADMIN_EMAILS.includes(emailLower)
} }
function sanitizeUserForClient(userDoc) { function sanitizeUserForClient(userDoc) {
@ -170,13 +181,14 @@ function createAuthRoutes(db) {
return { return {
async ensureAdminBootstrap() { async ensureAdminBootstrap() {
try { try {
if (!ROOT_ADMIN_EMAIL) return if (ROOT_ADMIN_EMAILS.length === 0) return
const now = new Date() const now = new Date()
const existing = await usersCollection.findOne({ email: ROOT_ADMIN_EMAIL }) for (const rootEmail of ROOT_ADMIN_EMAILS) {
const existing = await usersCollection.findOne({ email: rootEmail })
if (!existing) { if (!existing) {
await usersCollection.insertOne({ await usersCollection.insertOne({
email: ROOT_ADMIN_EMAIL, email: rootEmail,
nickname: ROOT_ADMIN_EMAIL.split('@')[0], nickname: rootEmail.split('@')[0],
avatar: null, avatar: null,
role: 'admin', role: 'admin',
permissions: getAdminDefaultPermissions(), permissions: getAdminDefaultPermissions(),
@ -185,8 +197,8 @@ function createAuthRoutes(db) {
updated_at: now, updated_at: now,
last_login: null last_login: null
}) })
console.log('[Auth] Root admin bootstrap user created') console.log('[Auth] Root admin bootstrap user created:', rootEmail)
return continue
} }
await usersCollection.updateOne( await usersCollection.updateOne(
{ _id: existing._id }, { _id: existing._id },
@ -201,7 +213,8 @@ function createAuthRoutes(db) {
} }
} }
) )
console.log('[Auth] Root admin bootstrap user ensured') console.log('[Auth] Root admin bootstrap user ensured:', rootEmail)
}
} catch (err) { } catch (err) {
console.error('[Auth] ensureAdminBootstrap error:', err) console.error('[Auth] ensureAdminBootstrap error:', err)
} }
@ -209,14 +222,24 @@ function createAuthRoutes(db) {
async sendCode(req, res) { async sendCode(req, res) {
try { try {
const { email } = req.body const { email, adminOnly } = req.body
if (!email) { if (!email) {
return res.status(400).json({ success: false, error: '邮箱不能为空' }) return res.status(400).json({ success: false, error: '邮箱不能为空' })
} }
if (WHITELIST_EMAILS.includes(email.toLowerCase())) { const emailLower = String(email).trim().toLowerCase()
if (adminOnly === true) {
const user = await usersCollection.findOne({ email: emailLower })
const canLoginAdmin = !!(user && isAdmin(user))
if (!canLoginAdmin) {
return res.status(403).json({ success: false, error: '仅管理员可获取登录验证码' })
}
}
if (WHITELIST_EMAILS.includes(emailLower)) {
return res.json({ success: true, message: '验证码已发送' }) return res.json({ success: true, message: '验证码已发送' })
} }
const result = await sendVerificationCode(db, email.toLowerCase()) const result = await sendVerificationCode(db, emailLower)
if (!result.success) { if (!result.success) {
return res.status(400).json(result) return res.status(400).json(result)
} }