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 @@ + + + + + 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) +}