diff --git a/app/layouts/default.vue b/app/layouts/default.vue
index 129d35d..1a85c8b 100644
--- a/app/layouts/default.vue
+++ b/app/layouts/default.vue
@@ -1,12 +1,23 @@
-
diff --git a/app/pages/admin/users.vue b/app/pages/admin/users.vue
new file mode 100644
index 0000000..7454871
--- /dev/null
+++ b/app/pages/admin/users.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.roleName }}
+
+
+
+
+
+
+ {{ getStatusText(row.status) }}
+
+
+
+
+
+ {{ row.lastLogin ? new Date(row.lastLogin).toLocaleString('zh-CN') : '-' }}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+ 密码至少6位,不能为纯数字
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 4f1775e..df95748 100644
--- a/package.json
+++ b/package.json
@@ -7,22 +7,31 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
- "postinstall": "nuxt prepare"
+ "postinstall": "nuxt prepare",
+ "user:cli": "node scripts/user-cli.js",
+ "user:init": "node scripts/user-cli.js init"
},
"dependencies": {
"@element-plus/nuxt": "1.0.10",
"@pinia/nuxt": "0.11.3",
+ "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.8.0",
"element-plus": "2.9.2",
+ "jsonwebtoken": "^9.0.3",
"nuxt": "^4.3.1",
"opencode-ai": "^1.2.27",
"pinia": "2.3.1",
+ "uuid": "^13.0.0",
"vue": "^3.5.29",
- "vue-router": "^4.6.4"
+ "vue-router": "^4.6.4",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@nuxt/eslint": "1.15.2",
- "@nuxt/icon": "2.2.1"
+ "@nuxt/icon": "2.2.1",
+ "@types/bcryptjs": "^3.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/uuid": "^11.0.0"
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
}
diff --git a/scripts/user-cli.js b/scripts/user-cli.js
new file mode 100644
index 0000000..3260ba5
--- /dev/null
+++ b/scripts/user-cli.js
@@ -0,0 +1,388 @@
+#!/usr/bin/env node
+
+import Database from 'better-sqlite3'
+import bcrypt from 'bcryptjs'
+import { v4 as uuidv4 } from 'uuid'
+import { join, dirname } from 'path'
+import { fileURLToPath } from 'url'
+import fs from 'fs'
+import readline from 'readline'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const projectRoot = join(__dirname, '..')
+const dataDir = join(projectRoot, 'data')
+const dbPath = join(dataDir, 'sports.db')
+
+if (!fs.existsSync(dbPath)) {
+ console.error('❌ 数据库文件不存在,请先运行项目')
+ process.exit(1)
+}
+
+const db = new Database(dbPath)
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+})
+
+function prompt(question) {
+ return new Promise((resolve) => rl.question(question, resolve))
+}
+
+function validatePassword(password) {
+ if (password.length < 6) {
+ return { valid: false, message: '密码长度至少6位' }
+ }
+ if (/^\d+$/.test(password)) {
+ return { valid: false, message: '密码不能为纯数字' }
+ }
+ return { valid: true }
+}
+
+function initRoles() {
+ const existingRoles = db.prepare('SELECT COUNT(*) as count FROM roles').get()
+
+ if (existingRoles.count === 0) {
+ console.log('📝 初始化默认角色...')
+
+ const defaultRoles = [
+ {
+ name: 'admin',
+ description: '系统管理员',
+ permissions: JSON.stringify([
+ 'user:create', 'user:read', 'user:update', 'user:delete',
+ 'event:create', 'event:read', 'event:update', 'event:delete',
+ 'result:create', 'result:read', 'result:update', 'result:delete',
+ 'team:create', 'team:read', 'team:update', 'team:delete',
+ 'admin:access', 'role:manage',
+ 'oauth:client:create', 'oauth:client:read', 'oauth:client:update', 'oauth:client:delete'
+ ]),
+ is_system: 1
+ },
+ {
+ name: 'user',
+ description: '普通用户',
+ permissions: JSON.stringify([
+ 'event:read', 'result:read', 'team:read'
+ ]),
+ is_system: 1
+ },
+ {
+ name: 'guest',
+ description: '访客',
+ permissions: JSON.stringify(['event:read']),
+ is_system: 1
+ }
+ ]
+
+ const stmt = db.prepare('INSERT INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)')
+ for (const role of defaultRoles) {
+ stmt.run(role.name, role.description, role.permissions, role.is_system)
+ }
+
+ console.log('✅ 默认角色初始化完成')
+ }
+}
+
+async function listUsers() {
+ console.log('\n📋 用户列表:\n')
+ console.log('ID | 用户名 | 邮箱 | 角色 | 状态 | 创建时间')
+ console.log('-'.repeat(70))
+
+ const users = db.prepare(`
+ SELECT u.*, r.name as role_name
+ FROM users u
+ LEFT JOIN roles r ON u.role_id = r.id
+ ORDER BY u.created_at DESC
+ `).all()
+
+ if (users.length === 0) {
+ console.log('暂无用户')
+ return
+ }
+
+ for (const user of users) {
+ console.log(
+ `${user.id} | ${user.username} | ${user.email || '-'} | ${user.role_name} | ${user.status} | ${user.created_at}`
+ )
+ }
+}
+
+async function createUser() {
+ const username = await prompt('用户名: ')
+ if (!username || username.trim().length < 2) {
+ console.log('❌ 用户名至少2个字符')
+ return
+ }
+
+ const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
+ if (existing) {
+ console.log('❌ 用户名已存在')
+ return
+ }
+
+ let password
+ while (true) {
+ password = await prompt('密码: ')
+ const validation = validatePassword(password)
+ if (validation.valid) break
+ console.log(`❌ ${validation.message}`)
+ }
+
+ const email = await prompt('邮箱 (可选): ')
+
+ let roleId = 2
+ const roles = db.prepare('SELECT id, name FROM roles').all()
+ if (roles.length > 0) {
+ console.log('\n可选角色:')
+ for (const role of roles) {
+ console.log(` ${role.id}. ${role.name}`)
+ }
+ const roleInput = await prompt('角色 ID (默认: 2): ')
+ if (roleInput && roles.find(r => r.id === parseInt(roleInput))) {
+ roleId = parseInt(roleInput)
+ }
+ }
+
+ const passwordHash = bcrypt.hashSync(password, 10)
+
+ const stmt = db.prepare(
+ 'INSERT INTO users (username, password_hash, email, role_id) VALUES (?, ?, ?, ?)'
+ )
+ const result = stmt.run(username, passwordHash, email || null, roleId)
+
+ console.log(`\n✅ 用户创建成功 (ID: ${result.lastInsertRowid})`)
+}
+
+async function deleteUser() {
+ const username = await prompt('要删除的用户名: ')
+
+ const user = db.prepare('SELECT id, username, role_id FROM users WHERE username = ?').get(username)
+ if (!user) {
+ console.log('❌ 用户不存在')
+ return
+ }
+
+ const role = db.prepare('SELECT is_system FROM roles WHERE id = ?').get(user.role_id)
+ if (role?.is_system) {
+ console.log('❌ 不能删除系统内置用户')
+ return
+ }
+
+ const confirm = await prompt(`确认删除用户 "${username}" ?(y/N): `)
+ if (confirm.toLowerCase() !== 'y') {
+ console.log('已取消')
+ return
+ }
+
+ db.prepare('DELETE FROM users WHERE id = ?').run(user.id)
+ console.log('✅ 用户已删除')
+}
+
+async function resetPassword() {
+ const username = await prompt('用户名: ')
+
+ const user = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
+ if (!user) {
+ console.log('❌ 用户不存在')
+ return
+ }
+
+ let password
+ while (true) {
+ password = await prompt('新密码: ')
+ const validation = validatePassword(password)
+ if (validation.valid) break
+ console.log(`❌ ${validation.message}`)
+ }
+
+ const passwordHash = bcrypt.hashSync(password, 10)
+ db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(passwordHash, user.id)
+
+ console.log('✅ 密码重置成功')
+}
+
+async function setRole() {
+ const username = await prompt('用户名: ')
+
+ const user = db.prepare('SELECT id, role_id FROM users WHERE username = ?').get(username)
+ if (!user) {
+ console.log('❌ 用户不存在')
+ return
+ }
+
+ const roles = db.prepare('SELECT id, name FROM roles').all()
+ console.log('\n可选角色:')
+ for (const role of roles) {
+ console.log(` ${role.id}. ${role.name}`)
+ }
+
+ const roleInput = await prompt('新角色 ID: ')
+ const newRole = roles.find(r => r.id === parseInt(roleInput))
+ if (!newRole) {
+ console.log('❌ 无效的角色 ID')
+ return
+ }
+
+ db.prepare('UPDATE users SET role_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newRole.id, user.id)
+ console.log(`✅ 已将 ${username} 的角色设置为 ${newRole.name}`)
+}
+
+async function setStatus() {
+ const username = await prompt('用户名: ')
+
+ const user = db.prepare('SELECT id, status FROM users WHERE username = ?').get(username)
+ if (!user) {
+ console.log('❌ 用户不存在')
+ return
+ }
+
+ console.log('\n可选状态:')
+ console.log(' 1. active (正常)')
+ console.log(' 2. inactive (停用)')
+ console.log(' 3. banned (封禁)')
+
+ const statusMap = { '1': 'active', '2': 'inactive', '3': 'banned' }
+ const input = await prompt('新状态: ')
+ const newStatus = statusMap[input]
+
+ if (!newStatus) {
+ console.log('❌ 无效的状态')
+ return
+ }
+
+ db.prepare('UPDATE users SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newStatus, user.id)
+ console.log(`✅ 用户状态已更新为 ${newStatus}`)
+}
+
+async function createOAuthClient() {
+ const clientName = await prompt('应用名称: ')
+ if (!clientName) {
+ console.log('❌ 应用名称不能为空')
+ return
+ }
+
+ const redirectUrisInput = await prompt('允许的重定向 URI (多个用逗号分隔): ')
+ const redirectUris = redirectUrisInput.split(',').map(uri => uri.trim()).filter(uri => uri)
+
+ console.log('\n支持的授权模式:')
+ console.log(' 1. authorization_code (授权码模式)')
+ console.log(' 2. password (密码模式)')
+ console.log(' 3. client_credentials (客户端凭据模式)')
+ console.log(' 4. refresh_token (刷新令牌)')
+
+ const grantTypeMap = {
+ '1': 'authorization_code',
+ '2': 'password',
+ '3': 'client_credentials',
+ '4': 'refresh_token'
+ }
+
+ const grantInput = await prompt('授权模式 (多个用逗号分隔,默认1): ')
+ const selectedGrants = grantInput
+ ? grantInput.split(',').map(g => grantTypeMap[g.trim()]).filter(g => g)
+ : ['authorization_code']
+
+ console.log('\n支持的平台:')
+ console.log(' 1. web (网页应用)')
+ console.log(' 2. mobile (移动应用)')
+ console.log(' 3. desktop (桌面应用)')
+ console.log(' 4. other (其他)')
+
+ const platformMap = { '1': 'web', '2': 'mobile', '3': 'desktop', '4': 'other' }
+ const platformInput = await prompt('平台 (默认1): ')
+ const platform = platformMap[platformInput] || 'web'
+
+ const clientId = uuidv4().replace(/-/g, '')
+ const clientSecret = uuidv4()
+ const clientSecretHash = bcrypt.hashSync(clientSecret, 10)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_clients
+ (client_id, client_secret_hash, client_name, redirect_uris, allowed_scopes, grant_types, platform)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `)
+ stmt.run(
+ clientId,
+ clientSecretHash,
+ clientName,
+ JSON.stringify(redirectUris),
+ JSON.stringify(['read', 'write']),
+ JSON.stringify(selectedGrants),
+ platform
+ )
+
+ console.log('\n✅ OAuth 客户端创建成功!\n')
+ console.log('='.repeat(50))
+ console.log('Client ID:')
+ console.log(clientId)
+ console.log('\nClient Secret:')
+ console.log(clientSecret)
+ console.log('='.repeat(50))
+ console.log('\n⚠️ 请妥善保存 Client Secret,它不会再次显示!\n')
+}
+
+async function showHelp() {
+ console.log(`
+🔧 运动会管理系统 - 用户管理工具
+
+用法: node scripts/user-cli.js
+
+命令:
+ list 列出所有用户
+ create 创建新用户
+ delete 删除用户
+ reset-password 重置用户密码
+ set-role 设置用户角色
+ set-status 设置用户状态
+ create-client 创建 OAuth 客户端
+ init 初始化默认角色
+ help 显示帮助信息
+
+示例:
+ node scripts/user-cli.js list
+ node scripts/user-cli.js create
+ node scripts/user-cli.js reset-password
+ `)
+}
+
+async function main() {
+ const args = process.argv.slice(2)
+ const command = args[0] || 'help'
+
+ console.log('🏃 运动会管理系统 - 用户管理 CLI\n')
+
+ switch (command) {
+ case 'list':
+ await listUsers()
+ break
+ case 'create':
+ await createUser()
+ break
+ case 'delete':
+ await deleteUser()
+ break
+ case 'reset-password':
+ await resetPassword()
+ break
+ case 'set-role':
+ await setRole()
+ break
+ case 'set-status':
+ await setStatus()
+ break
+ case 'create-client':
+ await createOAuthClient()
+ break
+ case 'init':
+ initRoles()
+ break
+ case 'help':
+ default:
+ await showHelp()
+ }
+
+ rl.close()
+}
+
+main().catch(console.error)
diff --git a/server/api/admin/oauth/index.ts b/server/api/admin/oauth/index.ts
new file mode 100644
index 0000000..a0a4b2a
--- /dev/null
+++ b/server/api/admin/oauth/index.ts
@@ -0,0 +1,150 @@
+import { getClients, createClient, updateClient, deleteClient } from '../../../modules/oauth'
+import { verifyToken } from '../../../utils/jwt'
+import { z } from 'zod'
+
+const createClientSchema = z.object({
+ clientName: z.string().min(1, '应用名称不能为空'),
+ redirectUris: z.array(z.string().url('请输入有效的URL')),
+ allowedScopes: z.array(z.string()),
+ grantTypes: z.array(z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token'])),
+ platform: z.enum(['web', 'mobile', 'desktop', 'other']).optional()
+})
+
+const updateClientSchema = z.object({
+ clientName: z.string().min(1).optional(),
+ redirectUris: z.array(z.string().url()).optional(),
+ allowedScopes: z.array(z.string()).optional(),
+ grantTypes: z.array(z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token'])).optional(),
+ platform: z.enum(['web', 'mobile', 'desktop', 'other']).optional(),
+ isActive: z.boolean().optional()
+})
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({ statusCode: 401, message: '请先登录' })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
+ throw createError({ statusCode: 403, message: '需要管理员权限' })
+ }
+
+ if (event.method === 'GET') {
+ const clients = getClients()
+
+ return {
+ success: true,
+ data: clients.map(c => ({
+ id: c.id,
+ clientId: c.client_id,
+ clientName: c.client_name,
+ redirectUris: c.redirect_uris,
+ allowedScopes: c.allowed_scopes,
+ grantTypes: c.grant_types,
+ platform: c.platform,
+ isActive: c.is_active === 1,
+ createdAt: c.created_at
+ }))
+ }
+ }
+
+ if (event.method === 'POST') {
+ const body = await readBody(event)
+
+ const result = createClientSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { clientName, redirectUris, allowedScopes, grantTypes, platform } = result.data
+
+ const { client, clientSecret } = createClient(clientName, redirectUris, allowedScopes, grantTypes, platform)
+
+ return {
+ success: true,
+ data: {
+ id: client.id,
+ clientId: client.client_id,
+ clientName: client.client_name,
+ clientSecret,
+ redirectUris: client.redirect_uris,
+ allowedScopes: client.allowed_scopes,
+ grantTypes: client.grant_types,
+ platform: client.platform,
+ isActive: client.is_active === 1
+ },
+ message: 'OAuth 客户端创建成功,请妥善保存 Client Secret'
+ }
+ }
+
+ if (event.method === 'PUT') {
+ const body = await readBody(event)
+ const query = getQuery(event)
+ const clientId = query.clientId as string
+
+ if (!clientId) {
+ throw createError({ statusCode: 400, message: '缺少 clientId 参数' })
+ }
+
+ const result = updateClientSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const updated = updateClient(clientId, {
+ client_name: result.data.clientName,
+ redirect_uris: result.data.redirectUris,
+ allowed_scopes: result.data.allowedScopes,
+ grant_types: result.data.grantTypes,
+ platform: result.data.platform,
+ is_active: result.data.isActive !== undefined ? (result.data.isActive ? 1 : 0) : undefined
+ })
+
+ if (!updated) {
+ throw createError({ statusCode: 404, message: '客户端不存在' })
+ }
+
+ return {
+ success: true,
+ data: {
+ clientId: updated!.client_id,
+ clientName: updated!.client_name,
+ redirectUris: updated!.redirect_uris,
+ allowedScopes: updated!.allowed_scopes,
+ grantTypes: updated!.grant_types,
+ platform: updated!.platform,
+ isActive: updated!.is_active === 1
+ },
+ message: '客户端更新成功'
+ }
+ }
+
+ if (event.method === 'DELETE') {
+ const query = getQuery(event)
+ const clientId = query.clientId as string
+
+ if (!clientId) {
+ throw createError({ statusCode: 400, message: '缺少 clientId 参数' })
+ }
+
+ const deleted = deleteClient(clientId)
+ if (!deleted) {
+ throw createError({ statusCode: 404, message: '客户端不存在' })
+ }
+
+ return {
+ success: true,
+ message: '客户端删除成功'
+ }
+ }
+})
diff --git a/server/api/admin/roles/index.ts b/server/api/admin/roles/index.ts
new file mode 100644
index 0000000..97e1913
--- /dev/null
+++ b/server/api/admin/roles/index.ts
@@ -0,0 +1,91 @@
+import { getRoles, createRole } from '../../../modules/auth/user'
+import { DEFAULT_ROLES, getPermissionGroups } from '../../../modules/auth/permissions'
+import { verifyToken } from '../../../utils/jwt'
+import { z } from 'zod'
+import db from '../../../db'
+
+const createRoleSchema = z.object({
+ name: z.string().min(2, '角色名称至少2个字符').max(20, '角色名称最多20个字符'),
+ description: z.string().optional(),
+ permissions: z.array(z.string())
+})
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({ statusCode: 401, message: '请先登录' })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
+ throw createError({ statusCode: 403, message: '需要管理员权限' })
+ }
+
+ const query = getQuery(event)
+
+ if (query.action === 'init') {
+ const existingRoles = getRoles()
+ if (existingRoles.length === 0) {
+ for (const role of Object.values(DEFAULT_ROLES)) {
+ createRole(role.name, role.description, role.permissions, role.isSystem)
+ }
+ }
+
+ const roles = getRoles()
+ const permissionGroups = getPermissionGroups()
+
+ return {
+ success: true,
+ data: {
+ roles,
+ permissionGroups
+ }
+ }
+ }
+
+ if (event.method === 'GET') {
+ const roles = getRoles()
+ const permissionGroups = getPermissionGroups()
+
+ return {
+ success: true,
+ data: {
+ roles,
+ permissionGroups
+ }
+ }
+ }
+
+ if (event.method === 'POST') {
+ const body = await readBody(event)
+
+ const result = createRoleSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { name, description, permissions } = result.data
+
+ const existingRole = db.prepare('SELECT id FROM roles WHERE name = ?').get(name)
+ if (existingRole) {
+ throw createError({
+ statusCode: 409,
+ message: '角色名称已存在'
+ })
+ }
+
+ const role = createRole(name, description || '', permissions)
+
+ return {
+ success: true,
+ data: role,
+ message: '角色创建成功'
+ }
+ }
+})
diff --git a/server/api/admin/users/index.get.ts b/server/api/admin/users/index.get.ts
new file mode 100644
index 0000000..3b85e01
--- /dev/null
+++ b/server/api/admin/users/index.get.ts
@@ -0,0 +1,40 @@
+import { getUsers } from '../../../modules/auth/user'
+import type { User } from '../../../modules/auth/types'
+import { verifyToken } from '../../../utils/jwt'
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({ statusCode: 401, message: '请先登录' })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
+ throw createError({ statusCode: 403, message: '需要管理员权限' })
+ }
+
+ const query = getQuery(event)
+ const roleId = query.roleId ? Number(query.roleId) : undefined
+ const status = query.status as string | undefined
+
+ const users = getUsers({ roleId, status })
+
+ return {
+ success: true,
+ data: users.map((u: User) => ({
+ id: u.id,
+ username: u.username,
+ email: u.email,
+ realName: u.real_name,
+ avatar: u.avatar,
+ roleId: u.role_id,
+ roleName: u.role_name,
+ status: u.status,
+ lastLogin: u.last_login,
+ createdAt: u.created_at
+ }))
+ }
+})
diff --git a/server/api/admin/users/index.post.ts b/server/api/admin/users/index.post.ts
new file mode 100644
index 0000000..303093c
--- /dev/null
+++ b/server/api/admin/users/index.post.ts
@@ -0,0 +1,81 @@
+import { z } from 'zod'
+import { getUserByUsername, getUserByEmail, createUser, hashPassword, validatePassword } from '../../../modules/auth/user'
+import { verifyToken } from '../../../utils/jwt'
+
+const createUserSchema = z.object({
+ username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
+ password: z.string(),
+ email: z.string().email('请输入有效的邮箱').optional().or(z.literal('')),
+ realName: z.string().optional(),
+ roleId: z.number().int().positive().optional()
+})
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({ statusCode: 401, message: '请先登录' })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
+ throw createError({ statusCode: 403, message: '需要管理员权限' })
+ }
+
+ const body = await readBody(event)
+
+ const result = createUserSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { username, password, email, realName, roleId } = result.data
+
+ const passwordValidation = validatePassword(password)
+ if (!passwordValidation.valid) {
+ throw createError({
+ statusCode: 400,
+ message: passwordValidation.message!
+ })
+ }
+
+ const existingUser = getUserByUsername(username)
+ if (existingUser) {
+ throw createError({
+ statusCode: 409,
+ message: '用户名已存在'
+ })
+ }
+
+ if (email) {
+ const existingEmail = getUserByEmail(email)
+ if (existingEmail) {
+ throw createError({
+ statusCode: 409,
+ message: '邮箱已被注册'
+ })
+ }
+ }
+
+ const passwordHash = hashPassword(password)
+ const user = await createUser(username, passwordHash, email, realName, roleId)
+
+ return {
+ success: true,
+ data: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ realName: user.real_name,
+ roleId: user.role_id,
+ roleName: user.role_name,
+ createdAt: user.created_at
+ },
+ message: '用户创建成功'
+ }
+})
diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts
new file mode 100644
index 0000000..326d47f
--- /dev/null
+++ b/server/api/auth/login.post.ts
@@ -0,0 +1,98 @@
+import { z } from 'zod'
+import { getUserByUsername, verifyPassword, resetLoginAttempts, updateLastLogin, incrementLoginAttempts, validatePassword } from '../../modules/auth/user'
+import { createToken } from '../../modules/oauth'
+import { generateAccessToken, generateRefreshToken } from '../../utils/jwt'
+
+const loginSchema = z.object({
+ username: z.string().min(1, '用户名不能为空'),
+ password: z.string().min(1, '密码不能为空')
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+
+ const result = loginSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { username, password } = result.data
+
+ const user = getUserByUsername(username)
+ if (!user) {
+ throw createError({
+ statusCode: 401,
+ message: '用户名或密码错误'
+ })
+ }
+
+ if (user.status !== 'active') {
+ throw createError({
+ statusCode: 403,
+ message: '账户已被禁用'
+ })
+ }
+
+ if (user.locked_until && new Date(user.locked_until) > new Date()) {
+ throw createError({
+ statusCode: 423,
+ message: `账户已被锁定,请于 ${new Date(user.locked_until).toLocaleString()} 后重试`
+ })
+ }
+
+ if (!verifyPassword(password, user.password_hash!)) {
+ incrementLoginAttempts(user.id)
+
+ const attempts = (user.login_attempts || 0) + 1
+ if (attempts >= 5) {
+ const lockedUntil = new Date(Date.now() + 30 * 60 * 1000)
+ incrementLoginAttempts(user.id, lockedUntil)
+ throw createError({
+ statusCode: 423,
+ message: '密码错误次数过多,账户已被锁定30分钟'
+ })
+ }
+
+ throw createError({
+ statusCode: 401,
+ message: `用户名或密码错误(剩余${5 - attempts}次)`
+ })
+ }
+
+ resetLoginAttempts(user.id)
+ updateLastLogin(user.id)
+
+ const payload = {
+ sub: user.username,
+ userId: user.id,
+ username: user.username,
+ role: user.role_name!,
+ permissions: user.permissions || []
+ }
+
+ const { accessToken, refreshToken } = createToken(user.id, 'system')
+ const jwtAccessToken = generateAccessToken(payload)
+ const jwtRefreshToken = generateRefreshToken(payload)
+
+ return {
+ success: true,
+ data: {
+ accessToken: jwtAccessToken,
+ refreshToken: jwtRefreshToken,
+ expiresIn: 7 * 24 * 60 * 60,
+ tokenType: 'Bearer',
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ realName: user.real_name,
+ avatar: user.avatar,
+ role: user.role_name,
+ permissions: user.permissions
+ }
+ }
+ }
+})
diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts
new file mode 100644
index 0000000..7e625ce
--- /dev/null
+++ b/server/api/auth/logout.post.ts
@@ -0,0 +1,21 @@
+import { verifyToken } from '../../utils/jwt'
+import { revokeToken } from '../../modules/oauth'
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (authHeader && authHeader.startsWith('Bearer ')) {
+ const token = authHeader.substring(7)
+ revokeToken(token, 'access')
+ }
+
+ const body = await readBody(event).catch(() => ({}))
+ if (body.refreshToken) {
+ revokeToken(body.refreshToken, 'refresh')
+ }
+
+ return {
+ success: true,
+ message: '登出成功'
+ }
+})
diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts
new file mode 100644
index 0000000..44fef06
--- /dev/null
+++ b/server/api/auth/me.get.ts
@@ -0,0 +1,53 @@
+import { verifyToken } from '../../utils/jwt'
+import { getUserById } from '../../modules/auth/user'
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({
+ statusCode: 401,
+ message: '未登录'
+ })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access') {
+ throw createError({
+ statusCode: 401,
+ message: '无效的访问令牌'
+ })
+ }
+
+ const user = getUserById(payload.userId)
+ if (!user) {
+ throw createError({
+ statusCode: 404,
+ message: '用户不存在'
+ })
+ }
+
+ if (user.status !== 'active') {
+ throw createError({
+ statusCode: 403,
+ message: '账户已被禁用'
+ })
+ }
+
+ return {
+ success: true,
+ data: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ realName: user.real_name,
+ avatar: user.avatar,
+ role: user.role_name,
+ permissions: user.permissions,
+ lastLogin: user.last_login,
+ createdAt: user.created_at
+ }
+ }
+})
diff --git a/server/api/auth/refresh.post.ts b/server/api/auth/refresh.post.ts
new file mode 100644
index 0000000..54ad5b5
--- /dev/null
+++ b/server/api/auth/refresh.post.ts
@@ -0,0 +1,59 @@
+import { z } from 'zod'
+import { verifyToken, generateAccessToken, generateRefreshToken } from '../../utils/jwt'
+import { getUserById } from '../../modules/auth/user'
+import { refreshAccessToken } from 'server/modules/oauth'
+
+const refreshSchema = z.object({
+ refreshToken: z.string().min(1, '刷新令牌不能为空')
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+
+ const result = refreshSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { refreshToken } = result.data
+
+ const payload = verifyToken(refreshToken)
+ if (!payload || payload.type !== 'refresh') {
+ throw createError({
+ statusCode: 401,
+ message: '无效的刷新令牌'
+ })
+ }
+
+ const user = getUserById(payload.userId)
+ if (!user || user.status !== 'active') {
+ throw createError({
+ statusCode: 401,
+ message: '用户不存在或已被禁用'
+ })
+ }
+
+ const newPayload = {
+ sub: user.username,
+ userId: user.id,
+ username: user.username,
+ role: user.role_name!,
+ permissions: user.permissions || []
+ }
+
+ const accessToken = generateAccessToken(newPayload)
+ const newRefreshToken = generateRefreshToken(newPayload)
+
+ return {
+ success: true,
+ data: {
+ accessToken,
+ refreshToken: newRefreshToken,
+ expiresIn: 7 * 24 * 60 * 60,
+ tokenType: 'Bearer'
+ }
+ }
+})
diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts
new file mode 100644
index 0000000..6fa7df6
--- /dev/null
+++ b/server/api/auth/register.post.ts
@@ -0,0 +1,65 @@
+import { z } from 'zod'
+import { getUserByUsername, getUserByEmail, createUser, hashPassword, validatePassword } from '../../modules/auth/user'
+
+const registerSchema = z.object({
+ username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
+ password: z.string(),
+ email: z.string().email('请输入有效的邮箱').optional().or(z.literal('')),
+ realName: z.string().optional()
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+
+ const result = registerSchema.safeParse(body)
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { username, password, email, realName } = result.data
+
+ const passwordValidation = validatePassword(password)
+ if (!passwordValidation.valid) {
+ throw createError({
+ statusCode: 400,
+ message: passwordValidation.message!
+ })
+ }
+
+ const existingUser = getUserByUsername(username)
+ if (existingUser) {
+ throw createError({
+ statusCode: 409,
+ message: '用户名已存在'
+ })
+ }
+
+ if (email) {
+ const existingEmail = getUserByEmail(email)
+ if (existingEmail) {
+ throw createError({
+ statusCode: 409,
+ message: '邮箱已被注册'
+ })
+ }
+ }
+
+ const passwordHash = hashPassword(password)
+ const user = await createUser(username, passwordHash, email, realName)
+
+ return {
+ success: true,
+ data: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ realName: user.real_name,
+ role: user.role_name,
+ createdAt: user.created_at
+ },
+ message: '注册成功'
+ }
+})
diff --git a/server/api/oauth/authorize.get.ts b/server/api/oauth/authorize.get.ts
new file mode 100644
index 0000000..0047246
--- /dev/null
+++ b/server/api/oauth/authorize.get.ts
@@ -0,0 +1,126 @@
+import { z } from 'zod'
+import { getClientById, validateRedirectUri, validateScope } from '../../modules/oauth'
+import { createAuthorizationCode } from '../../modules/oauth'
+
+const authorizeSchema = z.object({
+ response_type: z.enum(['code', 'token']),
+ client_id: z.string().min(1),
+ redirect_uri: z.string().min(1),
+ scope: z.string().optional(),
+ state: z.string().optional(),
+ code_challenge: z.string().optional(),
+ code_challenge_method: z.enum(['S256', 'plain']).optional()
+})
+
+export default defineEventHandler(async (event) => {
+ const query = getQuery(event) as Record
+
+ const result = authorizeSchema.safeParse({
+ response_type: query.response_type,
+ client_id: query.client_id,
+ redirect_uri: query.redirect_uri,
+ scope: query.scope,
+ state: query.state,
+ code_challenge: query.code_challenge,
+ code_challenge_method: query.code_challenge_method
+ })
+
+ if (!result.success) {
+ throw createError({
+ statusCode: 400,
+ message: result.error.errors[0].message
+ })
+ }
+
+ const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = result.data
+
+ const client = getClientById(client_id)
+ if (!client) {
+ throw createError({
+ statusCode: 400,
+ message: '无效的客户端'
+ })
+ }
+
+ if (!client.is_active) {
+ throw createError({
+ statusCode: 400,
+ message: '客户端已被禁用'
+ })
+ }
+
+ if (!validateRedirectUri(client, redirect_uri)) {
+ throw createError({
+ statusCode: 400,
+ message: '无效的重定向地址'
+ })
+ }
+
+ if (scope && !validateScope(client, scope)) {
+ throw createError({
+ statusCode: 400,
+ message: '无效的授权范围'
+ })
+ }
+
+ if (!client.grant_types.includes('authorization_code') && response_type === 'code') {
+ throw createError({
+ statusCode: 400,
+ message: '客户端不支持授权码模式'
+ })
+ }
+
+ if (!client.grant_types.includes('implicit') && response_type === 'token') {
+ throw createError({
+ statusCode: 400,
+ message: '客户端不支持隐式授权模式'
+ })
+ }
+
+ const authHeader = getHeader(event, 'authorization')
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({
+ statusCode: 401,
+ message: '请先登录'
+ })
+ }
+
+ const { verifyToken } = await import('../../utils/jwt')
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access') {
+ throw createError({
+ statusCode: 401,
+ message: '无效的访问令牌'
+ })
+ }
+
+ if (response_type === 'code') {
+ const code = createAuthorizationCode(
+ payload.userId,
+ client_id,
+ redirect_uri,
+ scope,
+ code_challenge,
+ code_challenge_method
+ )
+
+ const params = new URLSearchParams({ code })
+ if (state) params.append('state', state)
+
+ return sendRedirect(event, `${redirect_uri}?${params.toString()}`)
+ } else {
+ const { createTokenWithoutRefresh } = await import('../../modules/oauth')
+ const { accessToken, expiresAt } = createTokenWithoutRefresh(payload.userId, client_id, scope)
+
+ const fragmentParams = new URLSearchParams({
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: String(Math.floor((expiresAt.getTime() - Date.now()) / 1000))
+ })
+ if (state) fragmentParams.append('state', state)
+
+ return sendRedirect(event, `${redirect_uri}#${fragmentParams.toString()}`)
+ }
+})
diff --git a/server/api/oauth/token.post.ts b/server/api/oauth/token.post.ts
new file mode 100644
index 0000000..91076b1
--- /dev/null
+++ b/server/api/oauth/token.post.ts
@@ -0,0 +1,217 @@
+import { z } from 'zod'
+import {
+ getClientById,
+ verifyClientSecret,
+ createToken,
+ createClientCredentialsToken,
+ createTokenWithoutRefresh,
+ getAuthorizationCode,
+ useAuthorizationCode,
+ validateRedirectUri,
+ getTokenByRefreshToken,
+ revokeToken
+} from '../../modules/oauth'
+import { getUserByUsername, verifyPassword } from '../../modules/auth/user'
+
+const tokenSchema = z.object({
+ grant_type: z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token']),
+ client_id: z.string().min(1),
+ client_secret: z.string().optional()
+})
+
+const authCodeSchema = z.object({
+ code: z.string().min(1),
+ redirect_uri: z.string().min(1)
+})
+
+const passwordSchema = z.object({
+ username: z.string().min(1),
+ password: z.string().min(1),
+ scope: z.string().optional()
+})
+
+const clientCredsSchema = z.object({
+ scope: z.string().optional()
+})
+
+const refreshSchema = z.object({
+ refresh_token: z.string().min(1)
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+
+ const baseResult = tokenSchema.safeParse(body)
+ if (!baseResult.success) {
+ throw createError({
+ statusCode: 400,
+ message: baseResult.error.errors[0].message
+ })
+ }
+
+ const { grant_type, client_id, client_secret } = baseResult.data
+
+ const client = getClientById(client_id)
+ if (!client) {
+ throw createError({
+ statusCode: 400,
+ message: '无效的客户端'
+ })
+ }
+
+ if (client_secret !== undefined && !verifyClientSecret(client, client_secret)) {
+ throw createError({
+ statusCode: 401,
+ message: '客户端密钥错误'
+ })
+ }
+
+ switch (grant_type) {
+ case 'authorization_code': {
+ if (!client.grant_types.includes('authorization_code')) {
+ throw createError({ statusCode: 400, message: '客户端不支持授权码模式' })
+ }
+
+ const codeResult = authCodeSchema.safeParse(body)
+ if (!codeResult.success) {
+ throw createError({ statusCode: 400, message: codeResult.error.errors[0].message })
+ }
+
+ const { code, redirect_uri } = codeResult.data
+ const authCode = getAuthorizationCode(code)
+
+ if (!authCode) {
+ throw createError({ statusCode: 400, message: '无效或已过期的授权码' })
+ }
+
+ if (authCode.client_id !== client_id) {
+ throw createError({ statusCode: 400, message: '授权码与客户端不匹配' })
+ }
+
+ if (!validateRedirectUri(client, redirect_uri)) {
+ throw createError({ statusCode: 400, message: '重定向地址不匹配' })
+ }
+
+ if (authCode.code_challenge) {
+ throw createError({ statusCode: 400, message: '需要 PKCE 验证' })
+ }
+
+ useAuthorizationCode(code)
+
+ const { accessToken, refreshToken, expiresAt } = createToken(
+ authCode.user_id,
+ client_id,
+ authCode.scope || undefined
+ )
+
+ return {
+ success: true,
+ data: {
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
+ refresh_token: refreshToken,
+ scope: authCode.scope
+ }
+ }
+ }
+
+ case 'password': {
+ if (!client.grant_types.includes('password')) {
+ throw createError({ statusCode: 400, message: '客户端不支持密码模式' })
+ }
+
+ const pwdResult = passwordSchema.safeParse(body)
+ if (!pwdResult.success) {
+ throw createError({ statusCode: 400, message: pwdResult.error.errors[0].message })
+ }
+
+ const { username, password, scope } = pwdResult.data
+ const user = getUserByUsername(username)
+
+ if (!user || !verifyPassword(password, user.password_hash!)) {
+ throw createError({ statusCode: 401, message: '用户名或密码错误' })
+ }
+
+ if (user.status !== 'active') {
+ throw createError({ statusCode: 403, message: '账户已被禁用' })
+ }
+
+ const { accessToken, refreshToken, expiresAt } = createToken(user.id, client_id, scope)
+
+ return {
+ success: true,
+ data: {
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
+ refresh_token: refreshToken,
+ scope
+ }
+ }
+ }
+
+ case 'client_credentials': {
+ if (!client.grant_types.includes('client_credentials')) {
+ throw createError({ statusCode: 400, message: '客户端不支持客户端凭据模式' })
+ }
+
+ const credsResult = clientCredsSchema.safeParse(body)
+ if (!credsResult.success) {
+ throw createError({ statusCode: 400, message: credsResult.error.errors[0].message })
+ }
+
+ const { accessToken, expiresAt } = createClientCredentialsToken(client_id, credsResult.data.scope)
+
+ return {
+ success: true,
+ data: {
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
+ scope: credsResult.data.scope
+ }
+ }
+ }
+
+ case 'refresh_token': {
+ if (!client.grant_types.includes('refresh_token')) {
+ throw createError({ statusCode: 400, message: '客户端不支持刷新令牌模式' })
+ }
+
+ const refreshResult = refreshSchema.safeParse(body)
+ if (!refreshResult.success) {
+ throw createError({ statusCode: 400, message: refreshResult.error.errors[0].message })
+ }
+
+ const { refresh_token } = refreshResult.data
+ const oldToken = getTokenByRefreshToken(refresh_token)
+
+ if (!oldToken || oldToken.client_id !== client_id) {
+ throw createError({ statusCode: 400, message: '无效的刷新令牌' })
+ }
+
+ revokeToken(refresh_token, 'refresh')
+
+ const { accessToken, refreshToken: newRefreshToken, expiresAt } = createToken(
+ oldToken.user_id!,
+ client_id,
+ oldToken.scope || undefined
+ )
+
+ return {
+ success: true,
+ data: {
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
+ refresh_token: newRefreshToken,
+ scope: oldToken.scope
+ }
+ }
+ }
+
+ default:
+ throw createError({ statusCode: 400, message: '不支持的授权类型' })
+ }
+})
diff --git a/server/api/oauth/userinfo.get.ts b/server/api/oauth/userinfo.get.ts
new file mode 100644
index 0000000..0598952
--- /dev/null
+++ b/server/api/oauth/userinfo.get.ts
@@ -0,0 +1,53 @@
+import { verifyToken } from '../../utils/jwt'
+import { getUserById } from '../../modules/auth/user'
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw createError({
+ statusCode: 401,
+ message: '访问令牌不能为空'
+ })
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access') {
+ throw createError({
+ statusCode: 401,
+ message: '无效的访问令牌'
+ })
+ }
+
+ const user = getUserById(payload.userId)
+ if (!user) {
+ throw createError({
+ statusCode: 404,
+ message: '用户不存在'
+ })
+ }
+
+ const scope = getQuery(event).scope as string | undefined
+ const scopes = scope?.split(' ') || []
+
+ const userInfo: Record = {
+ sub: String(user.id),
+ name: user.real_name || user.username,
+ preferred_username: user.username
+ }
+
+ if (scopes.includes('email') || scopes.length === 0) {
+ userInfo.email = user.email
+ }
+
+ if (scopes.includes('profile') || scopes.length === 0) {
+ userInfo.profile = {
+ name: user.real_name,
+ picture: user.avatar
+ }
+ }
+
+ return userInfo
+})
diff --git a/server/api/seed.post.ts b/server/api/seed.post.ts
index 622bc81..6520e7d 100644
--- a/server/api/seed.post.ts
+++ b/server/api/seed.post.ts
@@ -1,16 +1,40 @@
import db from '../db'
+import bcrypt from 'bcryptjs'
-// 初始化种子数据
-export default defineEventHandler(() => {
+const ALL_PERMISSIONS = [
+ 'user:create', 'user:read', 'user:update', 'user:delete',
+ 'event:create', 'event:read', 'event:update', 'event:delete',
+ 'result:create', 'result:read', 'result:update', 'result:delete',
+ 'team:create', 'team:read', 'team:update', 'team:delete',
+ 'admin:access', 'role:manage',
+ 'oauth:client:create', 'oauth:client:read', 'oauth:client:update', 'oauth:client:delete'
+]
+
+const USER_PERMISSIONS = ['event:read', 'result:read', 'team:read']
+const GUEST_PERMISSIONS = ['event:read']
+
+export default defineEventHandler(async () => {
try {
- // 检查是否已有数据
- const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get()
+ const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get() as any
if (checkTeams.count > 0) {
return { success: true, message: '数据已存在,跳过初始化' }
}
- // 插入示例队伍
+ const insertRole = db.prepare('INSERT OR IGNORE INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)')
+ insertRole.run('admin', '系统管理员', JSON.stringify(ALL_PERMISSIONS), 1)
+ insertRole.run('user', '普通用户', JSON.stringify(USER_PERMISSIONS), 1)
+ insertRole.run('guest', '访客', JSON.stringify(GUEST_PERMISSIONS), 1)
+
+ const adminRole = db.prepare('SELECT id FROM roles WHERE name = ?').get('admin') as any
+ const passwordHash = bcrypt.hashSync('admin123', 10)
+ db.prepare(
+ 'INSERT INTO users (username, password_hash, role_id) VALUES (?, ?, ?)'
+ ).run('admin', passwordHash, adminRole.id)
+
+ const insertTeam = db.prepare('INSERT INTO teams (name, team_group) VALUES (?, ?)')
+ const insertScore = db.prepare('INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)')
+
const teams = [
{ name: '高一1班', group: '文化班甲组' },
{ name: '高一2班', group: '文化班甲组' },
@@ -23,52 +47,47 @@ export default defineEventHandler(() => {
{ name: '教师队', group: '教师组' }
]
- const insertTeam = db.prepare('INSERT INTO teams (name, team_group) VALUES (?, ?)')
- const insertScore = db.prepare('INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)')
-
teams.forEach(team => {
const result = insertTeam.run(team.name, team.group)
insertScore.run(result.lastInsertRowid)
})
- // 插入示例比赛项目
+ const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
+
const events = [
- // 田赛(个人赛)
{ name: '跳高', category: '田赛', event_group: '文化班甲组', unit: '米' },
{ name: '跳远', category: '田赛', event_group: '文化班甲组', unit: '米' },
{ name: '掷铅球', category: '田赛', event_group: '文化班乙组', unit: '米' },
-
- // 径赛(个人赛)
{ name: '100m', category: '径赛', event_group: '体育班组', unit: '秒' },
{ name: '200m', category: '径赛', event_group: '体育班组', unit: '秒' },
{ name: '400m', category: '径赛', event_group: '体育班组', unit: '秒' },
-
- // 径赛(接力赛/团体赛)
{ name: '4×100m', category: '团体赛', event_group: '航空班组', unit: '秒' },
{ name: '4×400m', category: '团体赛', event_group: '航空班组', unit: '秒' },
{ name: '20×50m', category: '团体赛', event_group: '航空班组', unit: '秒' },
-
- // 团体赛
{ name: '旱地龙舟', category: '团体赛', event_group: '教师组', unit: '秒' },
{ name: '跳长绳', category: '团体赛', event_group: '文化班甲组', unit: '次' },
{ name: '折返跑', category: '团体赛', event_group: '文化班乙组', unit: '秒' }
]
- const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
-
events.forEach(event => {
- insertEvent.run(event.name, event.category, event.group, event.unit)
+ insertEvent.run(event.name, event.category, event.event_group, event.unit)
})
return {
success: true,
message: '初始化数据成功',
data: {
+ users: 1,
+ roles: 3,
teams: teams.length,
- events: events.length
+ events: events.length,
+ adminAccount: {
+ username: 'admin',
+ password: 'admin123'
+ }
}
}
- } catch (error) {
+ } catch (error: any) {
console.error('Seed error:', error)
throw createError({
statusCode: 500,
diff --git a/server/db/index.ts b/server/db/index.ts
index f5b096b..21fbfef 100644
--- a/server/db/index.ts
+++ b/server/db/index.ts
@@ -53,6 +53,91 @@ db.exec(`
bronze_count INTEGER DEFAULT 0,
FOREIGN KEY (team_id) REFERENCES teams(id)
);
+
+ CREATE TABLE IF NOT EXISTS roles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ description TEXT,
+ permissions TEXT DEFAULT '[]',
+ is_system INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE,
+ password_hash TEXT NOT NULL,
+ real_name TEXT,
+ avatar TEXT,
+ role_id INTEGER DEFAULT 2,
+ status TEXT DEFAULT 'active',
+ last_login DATETIME,
+ login_attempts INTEGER DEFAULT 0,
+ locked_until DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (role_id) REFERENCES roles(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS oauth_clients (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id TEXT UNIQUE NOT NULL,
+ client_secret_hash TEXT NOT NULL,
+ client_name TEXT NOT NULL,
+ redirect_uris TEXT DEFAULT '[]',
+ allowed_scopes TEXT DEFAULT '[]',
+ grant_types TEXT DEFAULT '[]',
+ platform TEXT DEFAULT 'web',
+ is_active INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ client_id TEXT NOT NULL,
+ access_token TEXT UNIQUE NOT NULL,
+ refresh_token TEXT UNIQUE,
+ token_type TEXT DEFAULT 'Bearer',
+ scope TEXT,
+ expires_at DATETIME NOT NULL,
+ revoked INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS oauth_codes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT UNIQUE NOT NULL,
+ user_id INTEGER NOT NULL,
+ client_id TEXT NOT NULL,
+ redirect_uri TEXT NOT NULL,
+ scope TEXT,
+ code_challenge TEXT,
+ code_challenge_method TEXT,
+ expires_at DATETIME NOT NULL,
+ used INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS oauth_sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT UNIQUE NOT NULL,
+ user_id INTEGER NOT NULL,
+ client_id TEXT NOT NULL,
+ device_info TEXT,
+ ip_address TEXT,
+ last_activity DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
+ );
`)
export default db
diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts
new file mode 100644
index 0000000..e7800a1
--- /dev/null
+++ b/server/middleware/auth.ts
@@ -0,0 +1,96 @@
+import { verifyToken, decodeToken } from '../utils/jwt'
+import { getUserById } from '../modules/auth/user'
+import { hasPermission, PERMISSIONS } from '../modules/auth/permissions'
+import type { User } from 'server/modules/auth/types'
+
+declare module 'h3' {
+ interface H3EventContext {
+ user?: User
+ }
+}
+
+export default defineEventHandler(async (event) => {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access') {
+ return
+ }
+
+ const user = getUserById(payload.userId)
+ if (user && user.status === 'active') {
+ event.context.user = user
+ }
+})
+
+export function requireAuth(event: any) {
+ if (!event.context.user) {
+ throw createError({
+ statusCode: 401,
+ message: '请先登录'
+ })
+ }
+}
+
+export function requireRole(event: any, ...roles: string[]) {
+ requireAuth(event)
+
+ if (!event.context.user?.role_name) {
+ throw createError({
+ statusCode: 403,
+ message: '无权限访问'
+ })
+ }
+
+ if (!roles.includes(event.context.user.role_name)) {
+ throw createError({
+ statusCode: 403,
+ message: '无权限访问此资源'
+ })
+ }
+}
+
+export function requirePermission(event: any, permission: string) {
+ requireAuth(event)
+
+ const user = event.context.user
+ if (!user?.permissions || !hasPermission(user.permissions, permission)) {
+ if (hasPermission(user?.permissions || [], PERMISSIONS.ADMIN_ACCESS)) {
+ return
+ }
+ throw createError({
+ statusCode: 403,
+ message: '无此操作权限'
+ })
+ }
+}
+
+export function optionalAuth(event: any) {
+ const authHeader = getHeader(event, 'authorization')
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return
+ }
+
+ const token = authHeader.substring(7)
+ const payload = verifyToken(token)
+
+ if (!payload || payload.type !== 'access') {
+ return
+ }
+
+ const user = getUserById(payload.userId)
+ if (user && user.status === 'active') {
+ event.context.user = user
+ }
+}
+
+export function getCurrentUser(event: any): User | null {
+ return event.context.user || null
+}
diff --git a/server/modules/auth/permissions.ts b/server/modules/auth/permissions.ts
new file mode 100644
index 0000000..f5d7ee9
--- /dev/null
+++ b/server/modules/auth/permissions.ts
@@ -0,0 +1,128 @@
+export const PERMISSIONS = {
+ USER_CREATE: 'user:create',
+ USER_READ: 'user:read',
+ USER_UPDATE: 'user:update',
+ USER_DELETE: 'user:delete',
+
+ EVENT_CREATE: 'event:create',
+ EVENT_READ: 'event:read',
+ EVENT_UPDATE: 'event:update',
+ EVENT_DELETE: 'event:delete',
+
+ RESULT_CREATE: 'result:create',
+ RESULT_READ: 'result:read',
+ RESULT_UPDATE: 'result:update',
+ RESULT_DELETE: 'result:delete',
+
+ TEAM_CREATE: 'team:create',
+ TEAM_READ: 'team:read',
+ TEAM_UPDATE: 'team:update',
+ TEAM_DELETE: 'team:delete',
+
+ ADMIN_ACCESS: 'admin:access',
+ ROLE_MANAGE: 'role:manage',
+ OAUTH_CLIENT_CREATE: 'oauth:client:create',
+ OAUTH_CLIENT_READ: 'oauth:client:read',
+ OAUTH_CLIENT_UPDATE: 'oauth:client:update',
+ OAUTH_CLIENT_DELETE: 'oauth:client:delete'
+} as const
+
+export const DEFAULT_ROLES = {
+ admin: {
+ name: 'admin',
+ description: '系统管理员',
+ permissions: Object.values(PERMISSIONS),
+ isSystem: true
+ },
+ user: {
+ name: 'user',
+ description: '普通用户',
+ permissions: [
+ PERMISSIONS.EVENT_READ,
+ PERMISSIONS.RESULT_READ,
+ PERMISSIONS.TEAM_READ
+ ],
+ isSystem: true
+ },
+ guest: {
+ name: 'guest',
+ description: '访客',
+ permissions: [
+ PERMISSIONS.EVENT_READ
+ ],
+ isSystem: true
+ }
+}
+
+export function hasPermission(userPermissions: string[], requiredPermission: string): boolean {
+ if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
+ return true
+ }
+ return userPermissions.includes(requiredPermission)
+}
+
+export function hasAnyPermission(userPermissions: string[], requiredPermissions: string[]): boolean {
+ if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
+ return true
+ }
+ return requiredPermissions.some(p => userPermissions.includes(p))
+}
+
+export function hasAllPermissions(userPermissions: string[], requiredPermissions: string[]): boolean {
+ if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
+ return true
+ }
+ return requiredPermissions.every(p => userPermissions.includes(p))
+}
+
+export function getPermissionGroups(): Record {
+ return {
+ user: {
+ name: '用户管理',
+ permissions: [
+ PERMISSIONS.USER_CREATE,
+ PERMISSIONS.USER_READ,
+ PERMISSIONS.USER_UPDATE,
+ PERMISSIONS.USER_DELETE
+ ]
+ },
+ event: {
+ name: '比赛管理',
+ permissions: [
+ PERMISSIONS.EVENT_CREATE,
+ PERMISSIONS.EVENT_READ,
+ PERMISSIONS.EVENT_UPDATE,
+ PERMISSIONS.EVENT_DELETE
+ ]
+ },
+ result: {
+ name: '成绩管理',
+ permissions: [
+ PERMISSIONS.RESULT_CREATE,
+ PERMISSIONS.RESULT_READ,
+ PERMISSIONS.RESULT_UPDATE,
+ PERMISSIONS.RESULT_DELETE
+ ]
+ },
+ team: {
+ name: '队伍管理',
+ permissions: [
+ PERMISSIONS.TEAM_CREATE,
+ PERMISSIONS.TEAM_READ,
+ PERMISSIONS.TEAM_UPDATE,
+ PERMISSIONS.TEAM_DELETE
+ ]
+ },
+ system: {
+ name: '系统管理',
+ permissions: [
+ PERMISSIONS.ADMIN_ACCESS,
+ PERMISSIONS.ROLE_MANAGE,
+ PERMISSIONS.OAUTH_CLIENT_CREATE,
+ PERMISSIONS.OAUTH_CLIENT_READ,
+ PERMISSIONS.OAUTH_CLIENT_UPDATE,
+ PERMISSIONS.OAUTH_CLIENT_DELETE
+ ]
+ }
+ }
+}
diff --git a/server/modules/auth/types.ts b/server/modules/auth/types.ts
new file mode 100644
index 0000000..fab35a5
--- /dev/null
+++ b/server/modules/auth/types.ts
@@ -0,0 +1,100 @@
+export interface Role {
+ id: number
+ name: string
+ description?: string
+ permissions: string[]
+ is_system?: number
+ created_at?: string
+ updated_at?: string
+}
+
+export interface User {
+ id: number
+ username: string
+ email?: string
+ password_hash?: string
+ real_name?: string
+ avatar?: string
+ role_id: number
+ role_name?: string
+ permissions?: string[]
+ status: 'active' | 'inactive' | 'banned'
+ last_login?: string
+ login_attempts?: number
+ locked_until?: string
+ created_at?: string
+ updated_at?: string
+}
+
+export interface OAuthClient {
+ id: number
+ client_id: string
+ client_secret_hash?: string
+ client_name: string
+ redirect_uris: string[]
+ allowed_scopes: string[]
+ grant_types: string[]
+ platform: string
+ is_active: number
+ created_at?: string
+ updated_at?: string
+}
+
+export interface OAuthToken {
+ id: number
+ user_id?: number
+ client_id: string
+ access_token: string
+ refresh_token?: string
+ token_type: string
+ scope?: string
+ expires_at: string
+ revoked: number
+ created_at?: string
+}
+
+export interface OAuthCode {
+ id: number
+ code: string
+ user_id: number
+ client_id: string
+ redirect_uri: string
+ scope?: string
+ code_challenge?: string
+ code_challenge_method?: string
+ expires_at: string
+ used: number
+ created_at?: string
+}
+
+export interface OAuthSession {
+ id: number
+ session_id: string
+ user_id: number
+ client_id: string
+ device_info?: string
+ ip_address?: string
+ last_activity?: string
+ created_at?: string
+}
+
+export interface JWTPayload {
+ sub: string
+ userId: number
+ username: string
+ role: string
+ permissions: string[]
+ type: 'access' | 'refresh'
+ iat?: number
+ exp?: number
+}
+
+export interface LoginLog {
+ id: number
+ user_id: number
+ ip_address: string
+ user_agent: string
+ success: boolean
+ fail_reason?: string
+ created_at: string
+}
diff --git a/server/modules/auth/user.ts b/server/modules/auth/user.ts
new file mode 100644
index 0000000..5c5554f
--- /dev/null
+++ b/server/modules/auth/user.ts
@@ -0,0 +1,200 @@
+import db from '../../db'
+import bcrypt from 'bcryptjs'
+import type { User, Role } from './types'
+
+export const SALT_ROUNDS = 10
+
+export function getRoles(): Role[] {
+ const stmt = db.prepare('SELECT * FROM roles ORDER BY is_system DESC, id ASC')
+ const rows = stmt.all() as any[]
+ return rows.map(row => ({
+ ...row,
+ permissions: JSON.parse(row.permissions || '[]')
+ }))
+}
+
+export function getRoleByName(name: string): Role | undefined {
+ const stmt = db.prepare('SELECT * FROM roles WHERE name = ?')
+ const row = stmt.get(name) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ permissions: JSON.parse(row.permissions || '[]')
+ }
+}
+
+export function createRole(name: string, description: string, permissions: string[], isSystem = false): Role {
+ const stmt = db.prepare(
+ 'INSERT INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)'
+ )
+ const result = stmt.run(name, description, JSON.stringify(permissions), isSystem ? 1 : 0)
+ return {
+ id: Number(result.lastInsertRowid),
+ name,
+ description,
+ permissions,
+ is_system: isSystem,
+ created_at: new Date().toISOString()
+ }
+}
+
+export function getUsers(options?: { roleId?: number; status?: string }): User[] {
+ let sql = 'SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE 1=1'
+ const params: any[] = []
+
+ if (options?.roleId) {
+ sql += ' AND u.role_id = ?'
+ params.push(options.roleId)
+ }
+
+ if (options?.status) {
+ sql += ' AND u.status = ?'
+ params.push(options.status)
+ }
+
+ sql += ' ORDER BY u.created_at DESC'
+
+ const stmt = db.prepare(sql)
+ const rows = stmt.all(...params) as any[]
+ return rows.map(row => ({
+ ...row,
+ permissions: JSON.parse(row.role_permissions || '[]')
+ }))
+}
+
+export function getUserById(id: number): User | undefined {
+ const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.id = ?')
+ const row = stmt.get(id) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ permissions: JSON.parse(row.role_permissions || '[]')
+ }
+}
+
+export function getUserByUsername(username: string): User | undefined {
+ const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.username = ?')
+ const row = stmt.get(username) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ permissions: JSON.parse(row.role_permissions || '[]')
+ }
+}
+
+export function getUserByEmail(email: string): User | undefined {
+ const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.email = ?')
+ const row = stmt.get(email) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ permissions: JSON.parse(row.role_permissions || '[]')
+ }
+}
+
+export async function createUser(
+ username: string,
+ passwordHash: string,
+ email?: string,
+ realName?: string,
+ roleId = 2
+): Promise {
+ const stmt = db.prepare(
+ 'INSERT INTO users (username, password_hash, email, real_name, role_id) VALUES (?, ?, ?, ?, ?)'
+ )
+ const result = stmt.run(username, passwordHash, email || null, realName || null, roleId)
+ return getUserById(Number(result.lastInsertRowid))!
+}
+
+export async function updateUser(
+ id: number,
+ data: {
+ email?: string
+ realName?: string
+ roleId?: number
+ status?: string
+ avatar?: string
+ }
+): Promise {
+ const updates: string[] = []
+ const params: any[] = []
+
+ if (data.email !== undefined) {
+ updates.push('email = ?')
+ params.push(data.email)
+ }
+ if (data.realName !== undefined) {
+ updates.push('real_name = ?')
+ params.push(data.realName)
+ }
+ if (data.roleId !== undefined) {
+ updates.push('role_id = ?')
+ params.push(data.roleId)
+ }
+ if (data.status !== undefined) {
+ updates.push('status = ?')
+ params.push(data.status)
+ }
+ if (data.avatar !== undefined) {
+ updates.push('avatar = ?')
+ params.push(data.avatar)
+ }
+
+ if (updates.length === 0) return getUserById(id)
+
+ updates.push('updated_at = CURRENT_TIMESTAMP')
+ params.push(id)
+
+ const stmt = db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`)
+ stmt.run(...params)
+ return getUserById(id)
+}
+
+export async function updatePassword(id: number, newPasswordHash: string): Promise {
+ const stmt = db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ stmt.run(newPasswordHash, id)
+}
+
+export function deleteUser(id: number): boolean {
+ const stmt = db.prepare('DELETE FROM users WHERE id = ?')
+ const result = stmt.run(id)
+ return result.changes > 0
+}
+
+export function updateLastLogin(id: number): void {
+ const stmt = db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?')
+ stmt.run(id)
+}
+
+export function incrementLoginAttempts(id: number, lockedUntil?: Date): void {
+ if (lockedUntil) {
+ const stmt = db.prepare('UPDATE users SET login_attempts = login_attempts + 1, locked_until = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ stmt.run(lockedUntil.toISOString(), id)
+ } else {
+ const stmt = db.prepare('UPDATE users SET login_attempts = login_attempts + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ stmt.run(id)
+ }
+}
+
+export function resetLoginAttempts(id: number): void {
+ const stmt = db.prepare('UPDATE users SET login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ stmt.run(id)
+}
+
+export function hashPassword(password: string): string {
+ return bcrypt.hashSync(password, SALT_ROUNDS)
+}
+
+export function verifyPassword(password: string, hash: string): boolean {
+ return bcrypt.compareSync(password, hash)
+}
+
+export function validatePassword(password: string): { valid: boolean; message?: string } {
+ if (password.length < 6) {
+ return { valid: false, message: '密码长度至少6位' }
+ }
+ if (/^\d+$/.test(password)) {
+ return { valid: false, message: '密码不能为纯数字' }
+ }
+ return { valid: true }
+}
diff --git a/server/modules/oauth/index.ts b/server/modules/oauth/index.ts
new file mode 100644
index 0000000..4768ed2
--- /dev/null
+++ b/server/modules/oauth/index.ts
@@ -0,0 +1,266 @@
+import db from '../../db'
+import { v4 as uuidv4 } from 'uuid'
+import bcrypt from 'bcryptjs'
+import type { OAuthClient, OAuthToken, OAuthCode } from '../modules/auth/types'
+
+const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1000
+const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000
+const CODE_EXPIRY = 10 * 60 * 1000
+
+export function getClients(): OAuthClient[] {
+ const stmt = db.prepare('SELECT * FROM oauth_clients ORDER BY created_at DESC')
+ const rows = stmt.all() as any[]
+ return rows.map(row => ({
+ ...row,
+ redirect_uris: JSON.parse(row.redirect_uris || '[]'),
+ allowed_scopes: JSON.parse(row.allowed_scopes || '[]'),
+ grant_types: JSON.parse(row.grant_types || '[]')
+ }))
+}
+
+export function getClientById(clientId: string): OAuthClient | undefined {
+ const stmt = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?')
+ const row = stmt.get(clientId) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ redirect_uris: JSON.parse(row.redirect_uris || '[]'),
+ allowed_scopes: JSON.parse(row.allowed_scopes || '[]'),
+ grant_types: JSON.parse(row.grant_types || '[]')
+ }
+}
+
+export function createClient(
+ clientName: string,
+ redirectUris: string[],
+ allowedScopes: string[],
+ grantTypes: string[],
+ platform = 'web'
+): { client: OAuthClient; clientSecret: string } {
+ const clientId = uuidv4().replace(/-/g, '')
+ const clientSecret = uuidv4()
+ const clientSecretHash = bcrypt.hashSync(clientSecret, 10)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_clients
+ (client_id, client_secret_hash, client_name, redirect_uris, allowed_scopes, grant_types, platform)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `)
+ stmt.run(
+ clientId,
+ clientSecretHash,
+ clientName,
+ JSON.stringify(redirectUris),
+ JSON.stringify(allowedScopes),
+ JSON.stringify(grantTypes),
+ platform
+ )
+
+ return {
+ client: {
+ id: 0,
+ client_id: clientId,
+ client_name: clientName,
+ redirect_uris: redirectUris,
+ allowed_scopes: allowedScopes,
+ grant_types: grantTypes,
+ platform,
+ is_active: 1
+ },
+ clientSecret
+ }
+}
+
+export function verifyClientSecret(client: OAuthClient, clientSecret: string): boolean {
+ return bcrypt.compareSync(clientSecret, client.client_secret_hash || '')
+}
+
+export function updateClient(clientId: string, data: Partial): OAuthClient | undefined {
+ const updates: string[] = []
+ const params: any[] = []
+
+ if (data.client_name !== undefined) {
+ updates.push('client_name = ?')
+ params.push(data.client_name)
+ }
+ if (data.redirect_uris !== undefined) {
+ updates.push('redirect_uris = ?')
+ params.push(JSON.stringify(data.redirect_uris))
+ }
+ if (data.allowed_scopes !== undefined) {
+ updates.push('allowed_scopes = ?')
+ params.push(JSON.stringify(data.allowed_scopes))
+ }
+ if (data.grant_types !== undefined) {
+ updates.push('grant_types = ?')
+ params.push(JSON.stringify(data.grant_types))
+ }
+ if (data.platform !== undefined) {
+ updates.push('platform = ?')
+ params.push(data.platform)
+ }
+ if (data.is_active !== undefined) {
+ updates.push('is_active = ?')
+ params.push(data.is_active)
+ }
+
+ if (updates.length === 0) return getClientById(clientId)
+
+ updates.push('updated_at = CURRENT_TIMESTAMP')
+ params.push(clientId)
+
+ const stmt = db.prepare(`UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`)
+ stmt.run(...params)
+ return getClientById(clientId)
+}
+
+export function deleteClient(clientId: string): boolean {
+ const stmt = db.prepare('DELETE FROM oauth_clients WHERE client_id = ?')
+ const result = stmt.run(clientId)
+ return result.changes > 0
+}
+
+export function createToken(
+ userId: number,
+ clientId: string,
+ scope?: string
+): { accessToken: string; refreshToken: string; expiresAt: Date } {
+ const accessToken = uuidv4().replace(/-/g, '')
+ const refreshToken = uuidv4().replace(/-/g, '')
+ const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_tokens
+ (user_id, client_id, access_token, refresh_token, scope, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `)
+ stmt.run(userId, clientId, accessToken, refreshToken, scope || null, expiresAt.toISOString())
+
+ return { accessToken, refreshToken, expiresAt }
+}
+
+export function createTokenWithoutRefresh(
+ userId: number,
+ clientId: string,
+ scope?: string
+): { accessToken: string; expiresAt: Date } {
+ const accessToken = uuidv4().replace(/-/g, '')
+ const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_tokens
+ (user_id, client_id, access_token, scope, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `)
+ stmt.run(userId, clientId, accessToken, scope || null, expiresAt.toISOString())
+
+ return { accessToken, expiresAt }
+}
+
+export function createClientCredentialsToken(
+ clientId: string,
+ scope?: string
+): { accessToken: string; expiresAt: Date } {
+ const accessToken = uuidv4().replace(/-/g, '')
+ const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_tokens
+ (client_id, access_token, scope, expires_at)
+ VALUES (?, ?, ?, ?)
+ `)
+ stmt.run(clientId, accessToken, scope || null, expiresAt.toISOString())
+
+ return { accessToken, expiresAt }
+}
+
+export function getTokenByAccessToken(accessToken: string): OAuthToken | undefined {
+ const stmt = db.prepare('SELECT * FROM oauth_tokens WHERE access_token = ? AND revoked = 0')
+ const row = stmt.get(accessToken) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ refresh_token: row.refresh_token || undefined,
+ scope: row.scope || undefined
+ }
+}
+
+export function getTokenByRefreshToken(refreshToken: string): OAuthToken | undefined {
+ const stmt = db.prepare('SELECT * FROM oauth_tokens WHERE refresh_token = ? AND revoked = 0')
+ const row = stmt.get(refreshToken) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ scope: row.scope || undefined
+ }
+}
+
+export function refreshAccessToken(refreshToken: string): { accessToken: string; refreshToken: string; expiresAt: Date } | null {
+ const oldToken = getTokenByRefreshToken(refreshToken)
+ if (!oldToken) return null
+
+ revokeToken(refreshToken, 'refresh')
+ return createToken(oldToken.user_id!, oldToken.client_id, oldToken.scope || undefined)
+}
+
+export function revokeToken(token: string, type: 'access' | 'refresh' = 'access'): boolean {
+ const column = type === 'access' ? 'access_token' : 'refresh_token'
+ const stmt = db.prepare(`UPDATE oauth_tokens SET revoked = 1 WHERE ${column} = ?`)
+ const result = stmt.run(token)
+ return result.changes > 0
+}
+
+export function revokeAllUserTokens(userId: number): number {
+ const stmt = db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE user_id = ?')
+ const result = stmt.run(userId)
+ return result.changes
+}
+
+export function createAuthorizationCode(
+ userId: number,
+ clientId: string,
+ redirectUri: string,
+ scope?: string,
+ codeChallenge?: string,
+ codeChallengeMethod?: string
+): string {
+ const code = uuidv4().replace(/-/g, '')
+ const expiresAt = new Date(Date.now() + CODE_EXPIRY)
+
+ const stmt = db.prepare(`
+ INSERT INTO oauth_codes
+ (code, user_id, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `)
+ stmt.run(code, userId, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, expiresAt.toISOString())
+
+ return code
+}
+
+export function getAuthorizationCode(code: string): OAuthCode | undefined {
+ const stmt = db.prepare('SELECT * FROM oauth_codes WHERE code = ? AND used = 0 AND expires_at > datetime("now")')
+ const row = stmt.get(code) as any
+ if (!row) return undefined
+ return {
+ ...row,
+ scope: row.scope || undefined,
+ code_challenge: row.code_challenge || undefined,
+ code_challenge_method: row.code_challenge_method || undefined
+ }
+}
+
+export function useAuthorizationCode(code: string): boolean {
+ const stmt = db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?')
+ const result = stmt.run(code)
+ return result.changes > 0
+}
+
+export function validateRedirectUri(client: OAuthClient, redirectUri: string): boolean {
+ return client.redirect_uris.includes(redirectUri)
+}
+
+export function validateScope(client: OAuthClient, scope: string): boolean {
+ if (!scope) return true
+ const requestedScopes = scope.split(' ')
+ return requestedScopes.every(s => client.allowed_scopes.includes(s))
+}
diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts
new file mode 100644
index 0000000..0e684d5
--- /dev/null
+++ b/server/utils/jwt.ts
@@ -0,0 +1,45 @@
+import jwt from 'jsonwebtoken'
+import type { JWTPayload } from '../modules/auth/types'
+
+const JWT_SECRET = process.env.JWT_SECRET || 'sport-meeting-admin-secret-key-2026'
+const ACCESS_TOKEN_EXPIRY = '7d'
+const REFRESH_TOKEN_EXPIRY = '30d'
+
+export function generateAccessToken(payload: Omit): string {
+ return jwt.sign({ ...payload, type: 'access' }, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY })
+}
+
+export function generateRefreshToken(payload: Omit): string {
+ return jwt.sign({ ...payload, type: 'refresh' }, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY })
+}
+
+export function verifyToken(token: string): JWTPayload | null {
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
+ return decoded
+ } catch {
+ return null
+ }
+}
+
+export function decodeToken(token: string): JWTPayload | null {
+ try {
+ return jwt.decode(token) as JWTPayload
+ } catch {
+ return null
+ }
+}
+
+export function isAccessToken(token: JWTPayload): boolean {
+ return token.type === 'access'
+}
+
+export function isRefreshToken(token: JWTPayload): boolean {
+ return token.type === 'refresh'
+}
+
+export function getTokenExpiry(token: string): Date | null {
+ const decoded = decodeToken(token)
+ if (!decoded?.exp) return null
+ return new Date(decoded.exp * 1000)
+}