feat: add user center with RBAC, OAuth2 multi-mode and collapsible sidebar
- Add user management with roles and permissions (RBAC) - Implement OAuth2 service provider supporting 4 grant types: authorization_code, password, client_credentials, refresh_token - Add JWT authentication with 7-day expiry - Add admin API for users, roles and OAuth clients management - Add CLI tool for user management (scripts/user-cli.js) - Add collapsible sidebar layout with login dialog - Add user management page and OAuth client management page - Add server middleware for auth token verification - Add seed script for initial data (admin/admin123)
This commit is contained in:
parent
6f1fa04b1e
commit
8117958bd6
@ -1,12 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout-container">
|
<el-container class="layout-container">
|
||||||
<el-aside width="200px" class="sidebar desktop-only">
|
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<h2>运动会记分板</h2>
|
<h2 v-if="!collapsed">运动会记分板</h2>
|
||||||
|
<el-icon v-else :size="24"><Trophy /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-toggle">
|
||||||
|
<el-button text @click="toggleCollapse" size="small">
|
||||||
|
<el-icon>
|
||||||
|
<ArrowLeft v-if="!collapsed" />
|
||||||
|
<ArrowRight v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
class="sidebar-menu"
|
class="sidebar-menu"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
>
|
>
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
@ -17,6 +28,21 @@
|
|||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu v-if="hasAdminMenu" index="admin">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>系统管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users')">
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
<span>用户管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth')">
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
<span>OAuth 客户端</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
@ -27,6 +53,24 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<h3>运动会管理系统</h3>
|
<h3>运动会管理系统</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown v-if="currentUser" @command="handleUserCommand">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-avatar :size="32">
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
</el-avatar>
|
||||||
|
<span class="username">{{ currentUser.username }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-button v-else type="primary" @click="showLoginDialog = true">登录</el-button>
|
||||||
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main class="main-content">
|
<el-main class="main-content">
|
||||||
<slot />
|
<slot />
|
||||||
@ -60,16 +104,74 @@
|
|||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu v-if="hasAdminMenu" index="admin">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>系统管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users', true)">
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
<span>用户管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth', true)">
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
<span>OAuth 客户端</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="showLoginDialog" title="登录" width="400px">
|
||||||
|
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef">
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="用户名"
|
||||||
|
prefix-icon="User"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showLoginDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleLogin" :loading="logging">登录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu } from '@element-plus/icons-vue'
|
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
|
const showLoginDialog = ref(false)
|
||||||
|
const logging = ref(false)
|
||||||
|
const loginFormRef = ref()
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const currentUser = ref<any>(null)
|
||||||
|
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
|
||||||
|
|
||||||
|
const loginForm = ref({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginRules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/', label: '首页', icon: HomeFilled },
|
{ path: '/', label: '首页', icon: HomeFilled },
|
||||||
@ -79,26 +181,81 @@ const menuItems = [
|
|||||||
{ path: '/scoreboard', label: '记分板', icon: DataLine }
|
{ path: '/scoreboard', label: '记分板', icon: DataLine }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
localStorage.setItem('sidebar-collapsed', String(collapsed.value))
|
||||||
|
}
|
||||||
|
|
||||||
const onMenuClick = (path: string, closeDrawer = false) => {
|
const onMenuClick = (path: string, closeDrawer = false) => {
|
||||||
navigateTo(path)
|
navigateTo(path)
|
||||||
if (closeDrawer) {
|
if (closeDrawer) {
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUserCommand = async (command: string) => {
|
||||||
|
if (command === 'logout') {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
currentUser.value = null
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginFormRef.value) return
|
||||||
|
|
||||||
|
await loginFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
logging.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: loginForm.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
localStorage.setItem('token', res.data.accessToken)
|
||||||
|
localStorage.setItem('refreshToken', res.data.refreshToken)
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data.user))
|
||||||
|
currentUser.value = res.data.user
|
||||||
|
showLoginDialog.value = false
|
||||||
|
loginForm.value = { username: '', password: '' }
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '登录失败')
|
||||||
|
} finally {
|
||||||
|
logging.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedCollapsed = localStorage.getItem('sidebar-collapsed')
|
||||||
|
if (savedCollapsed !== null) {
|
||||||
|
collapsed.value = savedCollapsed === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedUser = localStorage.getItem('user')
|
||||||
|
if (savedUser) {
|
||||||
|
currentUser.value = JSON.parse(savedUser)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout-container {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background-color: #304156;
|
background-color: #304156;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
transition: width 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 60px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -107,8 +264,16 @@ const onMenuClick = (path: string, closeDrawer = false) => {
|
|||||||
|
|
||||||
.logo h2 {
|
.logo h2 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #263445;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu {
|
.sidebar-menu {
|
||||||
@ -116,12 +281,19 @@ const onMenuClick = (path: string, closeDrawer = false) => {
|
|||||||
background-color: #304156;
|
background-color: #304156;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu .el-menu-item {
|
.sidebar-menu:not(.el-menu--collapse) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .el-menu-item,
|
||||||
|
.sidebar-menu .el-sub-menu__title {
|
||||||
color: #bfcbd9;
|
color: #bfcbd9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu .el-menu-item:hover,
|
.sidebar-menu .el-menu-item:hover,
|
||||||
.sidebar-menu .el-menu-item.is-active {
|
.sidebar-menu .el-sub-menu__title:hover,
|
||||||
|
.sidebar-menu .el-menu-item.is-active,
|
||||||
|
.sidebar-menu .el-sub-menu .el-menu-item.is-active {
|
||||||
background-color: #263445;
|
background-color: #263445;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
}
|
}
|
||||||
@ -131,6 +303,7 @@ const onMenuClick = (path: string, closeDrawer = false) => {
|
|||||||
border-bottom: 1px solid #e6e6e6;
|
border-bottom: 1px solid #e6e6e6;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
}
|
}
|
||||||
@ -141,11 +314,27 @@ const onMenuClick = (path: string, closeDrawer = false) => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.header h3 {
|
.header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
background-color: #f0f2f5;
|
background-color: #f0f2f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
354
app/pages/admin/oauth.vue
Normal file
354
app/pages/admin/oauth.vue
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<div class="oauth-container">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>OAuth 客户端管理</span>
|
||||||
|
<el-button type="primary" @click="showAddDialog = true">创建客户端</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="clients" border stripe v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="clientName" label="应用名称" />
|
||||||
|
<el-table-column prop="clientId" label="Client ID" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="row.clientId" placement="top">
|
||||||
|
<code class="client-id">{{ row.clientId }}</code>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="platform" label="平台" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag>{{ getPlatformText(row.platform) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="grantTypes" label="授权模式" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
v-for="grant in row.grantTypes"
|
||||||
|
:key="grant"
|
||||||
|
size="small"
|
||||||
|
class="grant-tag"
|
||||||
|
>
|
||||||
|
{{ getGrantText(grant) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="isActive" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.isActive"
|
||||||
|
@change="toggleStatus(row)"
|
||||||
|
:disabled="row.platform === 'web'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ new Date(row.createdAt).toLocaleString('zh-CN') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" size="small" @click="deleteClient(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="创建 OAuth 客户端" width="600px">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
|
<el-form-item label="应用名称" prop="clientName">
|
||||||
|
<el-input v-model="form.clientName" placeholder="请输入应用名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="平台" prop="platform">
|
||||||
|
<el-radio-group v-model="form.platform">
|
||||||
|
<el-radio value="web">网页应用</el-radio>
|
||||||
|
<el-radio value="mobile">移动应用</el-radio>
|
||||||
|
<el-radio value="desktop">桌面应用</el-radio>
|
||||||
|
<el-radio value="other">其他</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="授权模式" prop="grantTypes">
|
||||||
|
<el-checkbox-group v-model="form.grantTypes">
|
||||||
|
<el-checkbox value="authorization_code">授权码模式</el-checkbox>
|
||||||
|
<el-checkbox value="password">密码模式</el-checkbox>
|
||||||
|
<el-checkbox value="client_credentials">客户端凭据模式</el-checkbox>
|
||||||
|
<el-checkbox value="refresh_token">刷新令牌</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="重定向 URI" prop="redirectUris">
|
||||||
|
<el-input
|
||||||
|
v-model="redirectUrisText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="多个 URI 用换行分隔"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">每行一个 URI</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showSecretDialog" title="Client Secret" width="500px">
|
||||||
|
<el-alert
|
||||||
|
title="请妥善保存 Client Secret"
|
||||||
|
type="warning"
|
||||||
|
description="Client Secret 只会显示一次,请立即复制保存"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="secret-display">
|
||||||
|
<p><strong>Client ID:</strong></p>
|
||||||
|
<code class="secret-value">{{ newClient.clientId }}</code>
|
||||||
|
<p><strong>Client Secret:</strong></p>
|
||||||
|
<code class="secret-value">{{ newClient.clientSecret }}</code>
|
||||||
|
<el-button type="primary" class="copy-btn" @click="copySecret">复制到剪贴板</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
const clients = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const showSecretDialog = ref(false)
|
||||||
|
const redirectUrisText = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
clientName: '',
|
||||||
|
platform: 'web',
|
||||||
|
grantTypes: ['authorization_code'] as string[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const newClient = ref({
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
clientName: [
|
||||||
|
{ required: true, message: '请输入应用名称', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
grantTypes: [
|
||||||
|
{ required: true, message: '请至少选择一种授权模式', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlatformText = (platform: string) => {
|
||||||
|
const texts: Record<string, string> = {
|
||||||
|
web: '网页',
|
||||||
|
mobile: '移动',
|
||||||
|
desktop: '桌面',
|
||||||
|
other: '其他'
|
||||||
|
}
|
||||||
|
return texts[platform] || platform
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGrantText = (grant: string) => {
|
||||||
|
const texts: Record<string, string> = {
|
||||||
|
authorization_code: '授权码',
|
||||||
|
password: '密码',
|
||||||
|
client_credentials: '客户端',
|
||||||
|
refresh_token: '刷新'
|
||||||
|
}
|
||||||
|
return texts[grant] || grant
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadClients = async () => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
navigateTo('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/admin/oauth', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
clients.value = res.data
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStatus = async (client: any) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: { isActive: client.isActive }
|
||||||
|
})
|
||||||
|
ElMessage.success('状态更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '更新失败')
|
||||||
|
loadClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteClient = async (client: any) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除客户端 "${client.clientName}" 吗?`, '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadClients()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const redirectUris = redirectUrisText.value
|
||||||
|
.split('\n')
|
||||||
|
.map(uri => uri.trim())
|
||||||
|
.filter(uri => uri)
|
||||||
|
|
||||||
|
if (redirectUris.length === 0) {
|
||||||
|
ElMessage.error('请至少输入一个重定向 URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/admin/oauth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: {
|
||||||
|
...form.value,
|
||||||
|
redirectUris,
|
||||||
|
allowedScopes: ['read', 'write']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
newClient.value = {
|
||||||
|
clientId: res.data.clientId,
|
||||||
|
clientSecret: res.data.clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddDialog.value = false
|
||||||
|
showSecretDialog.value = true
|
||||||
|
resetForm()
|
||||||
|
loadClients()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '创建失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {
|
||||||
|
clientName: '',
|
||||||
|
platform: 'web',
|
||||||
|
grantTypes: ['authorization_code']
|
||||||
|
}
|
||||||
|
redirectUrisText.value = ''
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySecret = () => {
|
||||||
|
const text = `Client ID: ${newClient.value.clientId}\nClient Secret: ${newClient.value.clientSecret}`
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadClients()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oauth-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grant-tag {
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-display {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-display p {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-value {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
315
app/pages/admin/users.vue
Normal file
315
app/pages/admin/users.vue
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="users-container">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户管理</span>
|
||||||
|
<el-button type="primary" @click="showAddDialog = true">添加用户</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" class="filter-form">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadUsers">
|
||||||
|
<el-option label="正常" value="active" />
|
||||||
|
<el-option label="停用" value="inactive" />
|
||||||
|
<el-option label="封禁" value="banned" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="users" border stripe v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="username" label="用户名" />
|
||||||
|
<el-table-column prop="email" label="邮箱" />
|
||||||
|
<el-table-column prop="realName" label="姓名" />
|
||||||
|
<el-table-column prop="roleName" label="角色" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.roleName === 'admin' ? 'danger' : 'info'">
|
||||||
|
{{ row.roleName }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="lastLogin" label="最后登录" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.lastLogin ? new Date(row.lastLogin).toLocaleString('zh-CN') : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" size="small" @click="editUser(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="deleteUser(row)"
|
||||||
|
:disabled="row.roleName === 'admin'"
|
||||||
|
>删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAddDialog" title="添加用户" width="500px">
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||||
|
<div class="form-tip">密码至少6位,不能为纯数字</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="form.email" placeholder="请输入邮箱(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="realName">
|
||||||
|
<el-input v-model="form.realName" placeholder="请输入姓名(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色" prop="roleId">
|
||||||
|
<el-select v-model="form.roleId" placeholder="请选择角色">
|
||||||
|
<el-option
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.id"
|
||||||
|
:label="role.description || role.name"
|
||||||
|
:value="role.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
const users = ref<any[]>([])
|
||||||
|
const roles = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const filters = ref({ status: '' })
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
id: null as number | null,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
realName: '',
|
||||||
|
roleId: null as number | null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '用户名2-20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
roleId: [
|
||||||
|
{ required: true, message: '请选择角色', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status: string) => {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
active: 'success',
|
||||||
|
inactive: 'warning',
|
||||||
|
banned: 'danger'
|
||||||
|
}
|
||||||
|
return types[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const texts: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
inactive: '停用',
|
||||||
|
banned: '封禁'
|
||||||
|
}
|
||||||
|
return texts[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
navigateTo('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.value.status) params.append('status', filters.value.status)
|
||||||
|
|
||||||
|
const res = await $fetch(`/api/admin/users?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
users.value = res.data
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '加载用户失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRoles = async () => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/admin/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
roles.value = res.data.roles
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load roles:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editUser = (user: any) => {
|
||||||
|
form.value = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
password: '',
|
||||||
|
email: user.email || '',
|
||||||
|
realName: user.realName || '',
|
||||||
|
roleId: user.roleId
|
||||||
|
}
|
||||||
|
showAddDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUser = async (user: any) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除用户 "${user.username}" 吗?`, '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
await $fetch(`/api/admin/users/${user.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (form.value.id) {
|
||||||
|
await $fetch(`/api/admin/users/${form.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: {
|
||||||
|
email: form.value.email,
|
||||||
|
realName: form.value.realName,
|
||||||
|
roleId: form.value.roleId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form.value
|
||||||
|
})
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddDialog.value = false
|
||||||
|
resetForm()
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {
|
||||||
|
id: null,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
realName: '',
|
||||||
|
roleId: null
|
||||||
|
}
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
loadRoles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.users-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-form-item) {
|
||||||
|
margin-right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
package.json
15
package.json
@ -7,22 +7,31 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"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": {
|
"dependencies": {
|
||||||
"@element-plus/nuxt": "1.0.10",
|
"@element-plus/nuxt": "1.0.10",
|
||||||
"@pinia/nuxt": "0.11.3",
|
"@pinia/nuxt": "0.11.3",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"element-plus": "2.9.2",
|
"element-plus": "2.9.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"opencode-ai": "^1.2.27",
|
"opencode-ai": "^1.2.27",
|
||||||
"pinia": "2.3.1",
|
"pinia": "2.3.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "1.15.2",
|
"@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"
|
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||||
}
|
}
|
||||||
|
|||||||
388
scripts/user-cli.js
Normal file
388
scripts/user-cli.js
Normal file
@ -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 <command>
|
||||||
|
|
||||||
|
命令:
|
||||||
|
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)
|
||||||
150
server/api/admin/oauth/index.ts
Normal file
150
server/api/admin/oauth/index.ts
Normal file
@ -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: '客户端删除成功'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
91
server/api/admin/roles/index.ts
Normal file
91
server/api/admin/roles/index.ts
Normal file
@ -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: '角色创建成功'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
40
server/api/admin/users/index.get.ts
Normal file
40
server/api/admin/users/index.get.ts
Normal file
@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
81
server/api/admin/users/index.post.ts
Normal file
81
server/api/admin/users/index.post.ts
Normal file
@ -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: '用户创建成功'
|
||||||
|
}
|
||||||
|
})
|
||||||
98
server/api/auth/login.post.ts
Normal file
98
server/api/auth/login.post.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/api/auth/logout.post.ts
Normal file
21
server/api/auth/logout.post.ts
Normal file
@ -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: '登出成功'
|
||||||
|
}
|
||||||
|
})
|
||||||
53
server/api/auth/me.get.ts
Normal file
53
server/api/auth/me.get.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
59
server/api/auth/refresh.post.ts
Normal file
59
server/api/auth/refresh.post.ts
Normal file
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
65
server/api/auth/register.post.ts
Normal file
65
server/api/auth/register.post.ts
Normal file
@ -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: '注册成功'
|
||||||
|
}
|
||||||
|
})
|
||||||
126
server/api/oauth/authorize.get.ts
Normal file
126
server/api/oauth/authorize.get.ts
Normal file
@ -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<string, string>
|
||||||
|
|
||||||
|
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()}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
217
server/api/oauth/token.post.ts
Normal file
217
server/api/oauth/token.post.ts
Normal file
@ -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: '不支持的授权类型' })
|
||||||
|
}
|
||||||
|
})
|
||||||
53
server/api/oauth/userinfo.get.ts
Normal file
53
server/api/oauth/userinfo.get.ts
Normal file
@ -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<string, any> = {
|
||||||
|
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
|
||||||
|
})
|
||||||
@ -1,16 +1,40 @@
|
|||||||
import db from '../db'
|
import db from '../db'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
// 初始化种子数据
|
const ALL_PERMISSIONS = [
|
||||||
export default defineEventHandler(() => {
|
'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 {
|
try {
|
||||||
// 检查是否已有数据
|
const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get() as any
|
||||||
const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get()
|
|
||||||
|
|
||||||
if (checkTeams.count > 0) {
|
if (checkTeams.count > 0) {
|
||||||
return { success: true, message: '数据已存在,跳过初始化' }
|
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 = [
|
const teams = [
|
||||||
{ name: '高一1班', group: '文化班甲组' },
|
{ name: '高一1班', group: '文化班甲组' },
|
||||||
{ name: '高一2班', group: '文化班甲组' },
|
{ name: '高一2班', group: '文化班甲组' },
|
||||||
@ -23,52 +47,47 @@ export default defineEventHandler(() => {
|
|||||||
{ name: '教师队', group: '教师组' }
|
{ 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 => {
|
teams.forEach(team => {
|
||||||
const result = insertTeam.run(team.name, team.group)
|
const result = insertTeam.run(team.name, team.group)
|
||||||
insertScore.run(result.lastInsertRowid)
|
insertScore.run(result.lastInsertRowid)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 插入示例比赛项目
|
const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
|
||||||
|
|
||||||
const events = [
|
const events = [
|
||||||
// 田赛(个人赛)
|
|
||||||
{ name: '跳高', category: '田赛', event_group: '文化班甲组', unit: '米' },
|
{ name: '跳高', category: '田赛', event_group: '文化班甲组', unit: '米' },
|
||||||
{ name: '跳远', category: '田赛', event_group: '文化班甲组', unit: '米' },
|
{ name: '跳远', category: '田赛', event_group: '文化班甲组', unit: '米' },
|
||||||
{ name: '掷铅球', category: '田赛', event_group: '文化班乙组', unit: '米' },
|
{ name: '掷铅球', category: '田赛', event_group: '文化班乙组', unit: '米' },
|
||||||
|
|
||||||
// 径赛(个人赛)
|
|
||||||
{ name: '100m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
{ name: '100m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
||||||
{ name: '200m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
{ name: '200m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
||||||
{ name: '400m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
{ name: '400m', category: '径赛', event_group: '体育班组', unit: '秒' },
|
||||||
|
|
||||||
// 径赛(接力赛/团体赛)
|
|
||||||
{ name: '4×100m', category: '团体赛', event_group: '航空班组', unit: '秒' },
|
{ name: '4×100m', category: '团体赛', event_group: '航空班组', unit: '秒' },
|
||||||
{ name: '4×400m', category: '团体赛', event_group: '航空班组', unit: '秒' },
|
{ name: '4×400m', category: '团体赛', event_group: '航空班组', unit: '秒' },
|
||||||
{ name: '20×50m', 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: '次' },
|
{ 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 => {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '初始化数据成功',
|
message: '初始化数据成功',
|
||||||
data: {
|
data: {
|
||||||
|
users: 1,
|
||||||
|
roles: 3,
|
||||||
teams: teams.length,
|
teams: teams.length,
|
||||||
events: events.length
|
events: events.length,
|
||||||
|
adminAccount: {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin123'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Seed error:', error)
|
console.error('Seed error:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|||||||
@ -53,6 +53,91 @@ db.exec(`
|
|||||||
bronze_count INTEGER DEFAULT 0,
|
bronze_count INTEGER DEFAULT 0,
|
||||||
FOREIGN KEY (team_id) REFERENCES teams(id)
|
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
|
export default db
|
||||||
|
|||||||
96
server/middleware/auth.ts
Normal file
96
server/middleware/auth.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
128
server/modules/auth/permissions.ts
Normal file
128
server/modules/auth/permissions.ts
Normal file
@ -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<string, { name: string; permissions: string[] }> {
|
||||||
|
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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
server/modules/auth/types.ts
Normal file
100
server/modules/auth/types.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
200
server/modules/auth/user.ts
Normal file
200
server/modules/auth/user.ts
Normal file
@ -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<User> {
|
||||||
|
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<User | undefined> {
|
||||||
|
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<void> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
266
server/modules/oauth/index.ts
Normal file
266
server/modules/oauth/index.ts
Normal file
@ -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>): 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))
|
||||||
|
}
|
||||||
45
server/utils/jwt.ts
Normal file
45
server/utils/jwt.ts
Normal file
@ -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<JWTPayload, 'iat' | 'exp' | 'type'>): string {
|
||||||
|
return jwt.sign({ ...payload, type: 'access' }, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp' | 'type'>): 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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user