Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
- Added middleware to handle invalid JSON body errors, returning a 400 status with a descriptive message. - Introduced functions to normalize and filter mode tags for user permissions, improving access control for skills based on user roles. - Updated API endpoints to incorporate mode tag filtering, ensuring users can only access skills they are permitted to view. - Implemented a new endpoint for updating skill tags, with permission checks for non-admin users. - Enhanced the frontend with updated styles and layout adjustments for better user experience.
584 lines
19 KiB
JavaScript
584 lines
19 KiB
JavaScript
const jwt = require('jsonwebtoken')
|
|
const { ObjectId } = require('mongodb')
|
|
const { sendVerificationCode, verifyCode } = require('../services/auth')
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
|
|
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_EMAILS = (process.env.WHITELIST_EMAILS || '')
|
|
.split(',')
|
|
.map((e) => e.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
|
|
const ALL_MODES = ['chat', 'clarify', 'cowork', 'create', 'video', 'code']
|
|
|
|
function getDefaultPermissions() {
|
|
return {
|
|
allowedModes: ['chat', 'clarify'],
|
|
canViewSkillsPage: false,
|
|
canViewAgentsPage: false,
|
|
canUseSshPage: false,
|
|
canUseDeveloperMode: false
|
|
}
|
|
}
|
|
|
|
function getAdminDefaultPermissions() {
|
|
return {
|
|
allowedModes: [...ALL_MODES],
|
|
canViewSkillsPage: true,
|
|
canViewAgentsPage: true,
|
|
canUseSshPage: true,
|
|
canUseDeveloperMode: true
|
|
}
|
|
}
|
|
|
|
function sanitizePermissions(raw) {
|
|
const defaults = getDefaultPermissions()
|
|
if (!raw || typeof raw !== 'object') return defaults
|
|
const next = { ...defaults }
|
|
let allowedModes = Array.isArray(raw.allowedModes)
|
|
? raw.allowedModes.filter((m) => ALL_MODES.includes(m))
|
|
: defaults.allowedModes
|
|
// Backward compatibility for old data where cowork/create were merged.
|
|
if (allowedModes.includes('cowork') && !allowedModes.includes('create')) {
|
|
const idx = allowedModes.indexOf('cowork')
|
|
if (idx >= 0) {
|
|
allowedModes = [
|
|
...allowedModes.slice(0, idx + 1),
|
|
'create',
|
|
...allowedModes.slice(idx + 1)
|
|
]
|
|
} else {
|
|
allowedModes = [...allowedModes, 'create']
|
|
}
|
|
}
|
|
next.allowedModes = allowedModes.length > 0 ? allowedModes : ['chat']
|
|
next.canViewSkillsPage = !!raw.canViewSkillsPage
|
|
next.canViewAgentsPage = !!raw.canViewAgentsPage
|
|
next.canUseSshPage = !!raw.canUseSshPage
|
|
next.canUseDeveloperMode = !!raw.canUseDeveloperMode
|
|
return next
|
|
}
|
|
|
|
function normalizeRole(role) {
|
|
return role === 'admin' ? 'admin' : 'user'
|
|
}
|
|
|
|
function isRootAdminEmail(email) {
|
|
const emailLower = String(email || '').toLowerCase()
|
|
return emailLower.length > 0 && ROOT_ADMIN_EMAILS.includes(emailLower)
|
|
}
|
|
|
|
function sanitizeUserForClient(userDoc) {
|
|
const role = normalizeRole(userDoc.role)
|
|
const permissions = sanitizePermissions(userDoc.permissions)
|
|
const isRootAdmin = role === 'admin' && isRootAdminEmail(userDoc.email)
|
|
return {
|
|
id: userDoc._id.toString(),
|
|
email: userDoc.email,
|
|
nickname: userDoc.nickname,
|
|
avatar: userDoc.avatar ?? null,
|
|
created_at: userDoc.created_at,
|
|
role,
|
|
permissions,
|
|
is_root_admin: isRootAdmin
|
|
}
|
|
}
|
|
|
|
function canAccessFeature(user, permissionKey) {
|
|
const perms = sanitizePermissions(user.permissions)
|
|
return !!perms[permissionKey]
|
|
}
|
|
|
|
function canUseMode(user, mode) {
|
|
const perms = sanitizePermissions(user.permissions)
|
|
return perms.allowedModes.includes(mode)
|
|
}
|
|
|
|
function isAdmin(user) {
|
|
return normalizeRole(user.role) === 'admin'
|
|
}
|
|
|
|
function isRootAdmin(user) {
|
|
return isAdmin(user) && isRootAdminEmail(user.email)
|
|
}
|
|
|
|
function getGrantablePermissions(actor) {
|
|
if (isRootAdmin(actor)) {
|
|
return {
|
|
allowedModes: [...ALL_MODES],
|
|
canViewSkillsPage: true,
|
|
canViewAgentsPage: true,
|
|
canUseSshPage: true,
|
|
canUseDeveloperMode: true
|
|
}
|
|
}
|
|
return sanitizePermissions(actor.permissions)
|
|
}
|
|
|
|
function clampPermissionsByGrantable(perms, grantable) {
|
|
return {
|
|
allowedModes: perms.allowedModes.filter((m) => grantable.allowedModes.includes(m)),
|
|
canViewSkillsPage: !!perms.canViewSkillsPage && !!grantable.canViewSkillsPage,
|
|
canViewAgentsPage: !!perms.canViewAgentsPage && !!grantable.canViewAgentsPage,
|
|
canUseSshPage: !!perms.canUseSshPage && !!grantable.canUseSshPage,
|
|
canUseDeveloperMode: !!perms.canUseDeveloperMode && !!grantable.canUseDeveloperMode
|
|
}
|
|
}
|
|
|
|
function signUserToken(userDoc) {
|
|
return jwt.sign(
|
|
{
|
|
userId: userDoc._id.toString(),
|
|
email: userDoc.email,
|
|
role: normalizeRole(userDoc.role)
|
|
},
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRES_IN }
|
|
)
|
|
}
|
|
|
|
function createAuthRoutes(db) {
|
|
const usersCollection = db.collection('users')
|
|
const permissionAuditCollection = db.collection('permission_audit_logs')
|
|
|
|
async function ensureUserDefaults(userDoc) {
|
|
const now = new Date()
|
|
const role = isRootAdminEmail(userDoc.email) ? 'admin' : normalizeRole(userDoc.role)
|
|
const permissionBase = role === 'admin' ? getAdminDefaultPermissions() : getDefaultPermissions()
|
|
const permissions = sanitizePermissions({ ...permissionBase, ...(userDoc.permissions || {}) })
|
|
const needRoleFix = userDoc.role !== role
|
|
const needPermFix = JSON.stringify(userDoc.permissions || null) !== JSON.stringify(permissions)
|
|
if (needRoleFix || needPermFix) {
|
|
await usersCollection.updateOne(
|
|
{ _id: userDoc._id },
|
|
{ $set: { role, permissions, updated_at: now } }
|
|
)
|
|
}
|
|
return { ...userDoc, role, permissions }
|
|
}
|
|
|
|
async function appendPermissionAuditLog({ actor, targetBefore, targetAfter, req }) {
|
|
await permissionAuditCollection.insertOne({
|
|
actor_user_id: actor.id,
|
|
actor_email: actor.email,
|
|
actor_nickname: actor.nickname || '',
|
|
actor_role: actor.role,
|
|
actor_is_root_admin: isRootAdmin(actor),
|
|
target_user_id: targetBefore._id.toString(),
|
|
target_email: targetBefore.email,
|
|
target_nickname: targetBefore.nickname || '',
|
|
target_role_before: normalizeRole(targetBefore.role),
|
|
target_role_after: normalizeRole(targetAfter.role),
|
|
target_permissions_before: sanitizePermissions(targetBefore.permissions),
|
|
target_permissions_after: sanitizePermissions(targetAfter.permissions),
|
|
changed_by_self: actor.id === targetBefore._id.toString(),
|
|
ip: req.ip || '',
|
|
user_agent: req.headers['user-agent'] || '',
|
|
created_at: new Date()
|
|
})
|
|
}
|
|
|
|
return {
|
|
async ensureAdminBootstrap() {
|
|
try {
|
|
if (ROOT_ADMIN_EMAILS.length === 0) return
|
|
const now = new Date()
|
|
for (const rootEmail of ROOT_ADMIN_EMAILS) {
|
|
const existing = await usersCollection.findOne({ email: rootEmail })
|
|
if (!existing) {
|
|
await usersCollection.insertOne({
|
|
email: rootEmail,
|
|
nickname: rootEmail.split('@')[0],
|
|
avatar: null,
|
|
role: 'admin',
|
|
permissions: getAdminDefaultPermissions(),
|
|
status: 'active',
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_login: null
|
|
})
|
|
console.log('[Auth] Root admin bootstrap user created:', rootEmail)
|
|
continue
|
|
}
|
|
await usersCollection.updateOne(
|
|
{ _id: existing._id },
|
|
{
|
|
$set: {
|
|
role: 'admin',
|
|
permissions: sanitizePermissions({
|
|
...getAdminDefaultPermissions(),
|
|
...(existing.permissions || {})
|
|
}),
|
|
updated_at: now
|
|
}
|
|
}
|
|
)
|
|
console.log('[Auth] Root admin bootstrap user ensured:', rootEmail)
|
|
}
|
|
} catch (err) {
|
|
console.error('[Auth] ensureAdminBootstrap error:', err)
|
|
}
|
|
},
|
|
|
|
async sendCode(req, res) {
|
|
try {
|
|
const { email, adminOnly } = req.body
|
|
if (!email) {
|
|
return res.status(400).json({ success: false, error: '邮箱不能为空' })
|
|
}
|
|
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: '验证码已发送' })
|
|
}
|
|
const result = await sendVerificationCode(db, emailLower)
|
|
if (!result.success) {
|
|
return res.status(400).json(result)
|
|
}
|
|
res.json({ success: true, message: '验证码已发送' })
|
|
} catch (err) {
|
|
console.error('[Auth] Send code error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async login(req, res) {
|
|
try {
|
|
const { email, code, nickname } = req.body
|
|
if (!email || !code) {
|
|
return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' })
|
|
}
|
|
const emailLower = email.toLowerCase()
|
|
|
|
if (WHITELIST_EMAILS.includes(emailLower)) {
|
|
if (code !== WHITELIST_CODE) {
|
|
return res.status(400).json({ success: false, error: '验证码错误' })
|
|
}
|
|
} else {
|
|
const verifyResult = await verifyCode(db, emailLower, code)
|
|
if (!verifyResult.success) {
|
|
return res.status(400).json(verifyResult)
|
|
}
|
|
}
|
|
|
|
let user = await usersCollection.findOne({ email: emailLower })
|
|
const now = new Date()
|
|
if (!user) {
|
|
const role = isRootAdminEmail(emailLower) ? 'admin' : 'user'
|
|
const newUser = {
|
|
email: emailLower,
|
|
nickname: nickname || emailLower.split('@')[0],
|
|
avatar: null,
|
|
role,
|
|
permissions: role === 'admin' ? getAdminDefaultPermissions() : getDefaultPermissions(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_login: now,
|
|
status: 'active'
|
|
}
|
|
const result = await usersCollection.insertOne(newUser)
|
|
user = { ...newUser, _id: result.insertedId }
|
|
} else {
|
|
user = await ensureUserDefaults(user)
|
|
await usersCollection.updateOne(
|
|
{ _id: user._id },
|
|
{ $set: { last_login: now, updated_at: now } }
|
|
)
|
|
user = { ...user, last_login: now, updated_at: now }
|
|
}
|
|
|
|
const token = signUserToken(user)
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
user: sanitizeUserForClient(user)
|
|
})
|
|
} catch (err) {
|
|
console.error('[Auth] Login error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async adminLogin(req, res) {
|
|
try {
|
|
const { email, code } = req.body
|
|
if (!email || !code) {
|
|
return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' })
|
|
}
|
|
const emailLower = String(email).trim().toLowerCase()
|
|
|
|
if (WHITELIST_EMAILS.includes(emailLower)) {
|
|
if (code !== WHITELIST_CODE) {
|
|
return res.status(400).json({ success: false, error: '验证码错误' })
|
|
}
|
|
} else {
|
|
const verifyResult = await verifyCode(db, emailLower, code)
|
|
if (!verifyResult.success) {
|
|
return res.status(400).json(verifyResult)
|
|
}
|
|
}
|
|
|
|
const user = await usersCollection.findOne({ email: emailLower })
|
|
if (!user) {
|
|
return res.status(403).json({ success: false, error: '仅管理员可登录管理系统' })
|
|
}
|
|
const safeUser = await ensureUserDefaults(user)
|
|
if (!isAdmin(safeUser)) {
|
|
return res.status(403).json({ success: false, error: '仅管理员可登录管理系统' })
|
|
}
|
|
|
|
await usersCollection.updateOne(
|
|
{ _id: safeUser._id },
|
|
{ $set: { last_login: new Date(), updated_at: new Date() } }
|
|
)
|
|
|
|
const token = signUserToken(safeUser)
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
user: sanitizeUserForClient(safeUser)
|
|
})
|
|
} catch (err) {
|
|
console.error('[Auth] Admin login error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async verifyToken(req, res, next) {
|
|
try {
|
|
const authHeader = req.headers.authorization
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return res.status(401).json({ success: false, error: '未登录' })
|
|
}
|
|
const token = authHeader.split(' ')[1]
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET)
|
|
const user = await usersCollection.findOne({ _id: new ObjectId(decoded.userId) })
|
|
if (!user) {
|
|
return res.status(401).json({ success: false, error: '用户不存在' })
|
|
}
|
|
const safeUser = await ensureUserDefaults(user)
|
|
req.user = sanitizeUserForClient(safeUser)
|
|
next()
|
|
} catch {
|
|
return res.status(401).json({ success: false, error: 'Token无效或已过期' })
|
|
}
|
|
} catch (err) {
|
|
console.error('[Auth] Verify token error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async verifyAdmin(req, res, next) {
|
|
await this.verifyToken(req, res, () => {
|
|
if (!isAdmin(req.user)) {
|
|
return res.status(403).json({ success: false, error: '需要管理员权限' })
|
|
}
|
|
next()
|
|
})
|
|
},
|
|
|
|
hasPermission(user, permissionKey) {
|
|
return canAccessFeature(user, permissionKey)
|
|
},
|
|
|
|
isModeAllowed(user, mode) {
|
|
return canUseMode(user, mode)
|
|
},
|
|
|
|
getProfile(req, res) {
|
|
try {
|
|
res.json({ success: true, user: req.user })
|
|
} catch (err) {
|
|
console.error('[Auth] Get profile error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async updateProfile(req, res) {
|
|
try {
|
|
const { nickname, avatar } = req.body
|
|
const updateData = { updated_at: new Date() }
|
|
if (nickname) updateData.nickname = nickname
|
|
if (avatar) updateData.avatar = avatar
|
|
await usersCollection.updateOne({ _id: new ObjectId(req.user.id) }, { $set: updateData })
|
|
const updatedUser = await usersCollection.findOne({ _id: new ObjectId(req.user.id) })
|
|
const safeUser = updatedUser
|
|
? sanitizeUserForClient(updatedUser)
|
|
: { ...req.user, ...updateData }
|
|
res.json({ success: true, user: safeUser })
|
|
} catch (err) {
|
|
console.error('[Auth] Update profile error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async listUsers(req, res) {
|
|
try {
|
|
const keyword = String(req.query.query || '').trim()
|
|
const filter = keyword
|
|
? {
|
|
$or: [
|
|
{ email: { $regex: keyword, $options: 'i' } },
|
|
{ nickname: { $regex: keyword, $options: 'i' } }
|
|
]
|
|
}
|
|
: {}
|
|
const list = await usersCollection
|
|
.find(filter, {
|
|
projection: {
|
|
email: 1,
|
|
nickname: 1,
|
|
avatar: 1,
|
|
role: 1,
|
|
permissions: 1,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
last_login: 1,
|
|
status: 1
|
|
}
|
|
})
|
|
.sort({ created_at: -1 })
|
|
.toArray()
|
|
const actorIsRoot = isRootAdmin(req.user)
|
|
const grantable = getGrantablePermissions(req.user)
|
|
const users = list.map((u) => {
|
|
const safe = sanitizeUserForClient(u)
|
|
const targetIsAdmin = safe.role === 'admin'
|
|
const targetIsSelf = safe.id === req.user.id
|
|
const editable = actorIsRoot
|
|
? true
|
|
: !targetIsAdmin && !targetIsSelf
|
|
return { ...safe, editable }
|
|
})
|
|
res.json({
|
|
success: true,
|
|
total: users.length,
|
|
users,
|
|
admin_capabilities: {
|
|
grantable_permissions: grantable,
|
|
canEditAdmins: actorIsRoot,
|
|
canEditSelf: actorIsRoot
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('[Auth] listUsers error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async updateUserPermissions(req, res) {
|
|
try {
|
|
const userId = req.params.userId
|
|
if (!userId) {
|
|
return res.status(400).json({ success: false, error: 'userId required' })
|
|
}
|
|
const target = await usersCollection.findOne({ _id: new ObjectId(userId) })
|
|
if (!target) {
|
|
return res.status(404).json({ success: false, error: '用户不存在' })
|
|
}
|
|
|
|
const actor = req.user
|
|
const actorIsRoot = isRootAdmin(actor)
|
|
const grantable = getGrantablePermissions(actor)
|
|
const targetRole = normalizeRole(target.role)
|
|
const isSelf = target._id.toString() === actor.id
|
|
const patch = req.body || {}
|
|
const requestedRole =
|
|
patch.role !== undefined ? normalizeRole(patch.role) : targetRole
|
|
|
|
if (!actorIsRoot) {
|
|
if (isSelf) {
|
|
return res.status(403).json({ success: false, error: '不能修改自己的权限' })
|
|
}
|
|
if (targetRole === 'admin') {
|
|
return res.status(403).json({ success: false, error: '不能修改管理员权限' })
|
|
}
|
|
if (requestedRole === 'admin') {
|
|
return res.status(403).json({ success: false, error: '仅初始管理员可授予管理员权限' })
|
|
}
|
|
}
|
|
|
|
const nextRole = requestedRole
|
|
const mergedRawPerms = patch.permissions
|
|
? { ...(target.permissions || {}), ...patch.permissions }
|
|
: (target.permissions || {})
|
|
const nextPermissionsBase = sanitizePermissions(mergedRawPerms)
|
|
const nextPermissions = actorIsRoot
|
|
? nextPermissionsBase
|
|
: clampPermissionsByGrantable(nextPermissionsBase, grantable)
|
|
const now = new Date()
|
|
|
|
await usersCollection.updateOne(
|
|
{ _id: target._id },
|
|
{
|
|
$set: {
|
|
role: nextRole,
|
|
permissions: nextPermissions,
|
|
updated_at: now
|
|
}
|
|
}
|
|
)
|
|
|
|
const updated = await usersCollection.findOne({ _id: target._id })
|
|
await appendPermissionAuditLog({
|
|
actor,
|
|
targetBefore: target,
|
|
targetAfter: updated,
|
|
req
|
|
})
|
|
res.json({ success: true, user: sanitizeUserForClient(updated) })
|
|
} catch (err) {
|
|
console.error('[Auth] updateUserPermissions error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
},
|
|
|
|
async listPermissionAuditLogs(req, res) {
|
|
try {
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200)
|
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0)
|
|
const logs = await permissionAuditCollection
|
|
.find({}, { projection: { _id: 0 } })
|
|
.sort({ created_at: -1 })
|
|
.skip(offset)
|
|
.limit(limit)
|
|
.toArray()
|
|
const total = await permissionAuditCollection.countDocuments({})
|
|
res.json({ success: true, total, logs })
|
|
} catch (err) {
|
|
console.error('[Auth] listPermissionAuditLogs error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createAuthRoutes,
|
|
ALL_MODES,
|
|
getDefaultPermissions,
|
|
sanitizePermissions
|
|
}
|