This commit is contained in:
2025-07-29 15:36:25 -07:00
commit 0c481c7a0e
29 changed files with 6682 additions and 0 deletions

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# 依赖
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 环境变量
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 日志
logs
*.log
# 运行时数据
pids
*.pid
*.seed
*.pid.lock
# 覆盖率目录
coverage/
.nyc_output
# 依赖目录
node_modules/
# 可选npm缓存目录
.npm
# 可选eslint缓存
.eslintcache
# 输出目录
dist/
build/
# 临时文件
.tmp/
.temp/
# IDE文件
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db

406
README.md Normal file
View File

@ -0,0 +1,406 @@
# OAuth 2.0 提供商服务
这是一个完整的OAuth 2.0提供商服务支持Authorization Code Flow包含用户认证、OAuth客户端管理、令牌管理等功能。
## 功能特性
### 🔐 用户认证
- 用户注册和登录
- JWT令牌认证
- 密码加密存储
### 🚀 OAuth 2.0 功能
- **Authorization Code Flow** - 完整的授权码流程
- **Access Token** - 访问令牌生成和验证
- **Refresh Token** - 刷新令牌支持
- **Token Revocation** - 令牌撤销功能
- **PKCE Support** - 支持PKCEProof Key for Code Exchange
- **Scope Management** - 权限范围管理
### 🛡️ 安全特性
- 客户端密钥管理
- 令牌过期机制
- 自动清理过期令牌
- 重定向URI验证
- 客户端所有权验证
### 📊 OAuth客户端管理
- 创建OAuth客户端
- 获取客户端列表
- 获取客户端详情
- 获取客户端密钥
- 重置客户端密钥
- 删除客户端
## API端点
### 用户认证
```
POST /api/auth/register - 用户注册
POST /api/auth/login - 用户登录
GET /api/auth/profile - 获取用户信息
```
### OAuth端点
```
GET /api/oauth/authorize - 授权端点
POST /api/oauth/token - 令牌端点
POST /api/oauth/revoke - 撤销端点
GET /api/oauth/userinfo - 用户信息端点
GET /api/oauth/tokeninfo - 令牌信息端点
```
### OAuth客户端管理
```
POST /api/oauth/clients - 创建客户端
GET /api/oauth/clients - 获取客户端列表
GET /api/oauth/clients/:clientId - 获取客户端详情
GET /api/oauth/clients/:clientId/secret - 获取客户端密钥
POST /api/oauth/clients/:clientId/reset-secret - 重置客户端密钥
DELETE /api/oauth/clients/:clientId - 删除客户端
```
### 发现端点
```
GET /.well-known/oauth-authorization-server - OAuth发现端点
```
## 安装和运行
### 1. 安装依赖
```bash
npm install
```
### 2. 配置环境变量
创建 `.env` 文件:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=your_database
DB_USER=your_username
DB_PASSWORD=your_password
JWT_SECRET=your_jwt_secret_key
PORT=3000
```
### 3. 启动服务
```bash
# 开发模式
npm run dev
# 生产模式
npm start
```
## 前端界面
本项目包含两个前端版本:
### 🚀 React + Material-UI 版本(推荐)
现代化的React应用使用Material-UI组件库。
#### 安装和运行
```bash
# 安装前端依赖
npm install
# 启动前端开发服务器
npm run dev:frontend
# 构建生产版本
npm run build:frontend
```
#### 功能特性
-**响应式设计** - 适配各种屏幕尺寸
-**现代化UI** - Material-UI设计语言
-**路由管理** - React Router
-**状态管理** - Context API
-**表单验证** - 实时验证和错误提示
-**加载状态** - 优雅的加载动画
-**错误处理** - 友好的错误提示
#### 页面说明
1. **登录页面** (`/login`)
- 用户名/密码登录
- 密码可见性切换
- 表单验证
- 自动跳转到注册
2. **注册页面** (`/register`)
- 用户注册表单
- 密码确认验证
- 邮箱格式验证
- 自动跳转到登录
3. **个人中心** (`/dashboard`)
- 用户信息展示
- OAuth客户端管理
- 创建/删除客户端
- 退出登录
4. **OAuth授权页面** (`/oauth/authorize`)
- 第三方应用授权
- 权限范围展示
- 用户信息确认
- 授权/拒绝操作
### 📄 HTML + Bootstrap 版本
简单的HTML版本使用Bootstrap样式。
#### 使用方法
```bash
# 直接打开HTML文件
open public/index.html
```
#### 功能特性
-**轻量级** - 无需构建工具
-**Bootstrap样式** - 美观的界面
-**原生JavaScript** - 简单易懂
-**登录/注册切换** - 单页面应用
-**表单验证** - 客户端验证
## 使用示例
### 1. 启动后端服务
```bash
# 启动后端API服务
npm start
```
### 2. 启动前端服务
```bash
# 启动React前端
npm run dev:frontend
```
### 3. 访问应用
- **React版本**: http://localhost:3001
- **HTML版本**: 直接打开 `public/index.html`
### 4. 测试OAuth流程
1. 注册/登录用户
2. 在个人中心创建OAuth客户端
3. 访问授权页面:`http://localhost:3001/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3001/callback&scope=read%20write&state=test123`
4. 完成授权流程
## 前端技术栈
### React版本
- **React 18** - 用户界面库
- **Material-UI 5** - 组件库
- **React Router 6** - 路由管理
- **Axios** - HTTP客户端
- **Vite** - 构建工具
### HTML版本
- **Bootstrap 5** - CSS框架
- **Font Awesome** - 图标库
- **原生JavaScript** - 交互逻辑
## 开发说明
### 目录结构
```
├── src/ # React源代码
│ ├── components/ # 组件
│ ├── contexts/ # 上下文
│ ├── pages/ # 页面
│ ├── App.jsx # 主应用
│ └── main.jsx # 入口文件
├── public/ # 静态资源
│ └── index.html # HTML版本
├── index.html # React入口
├── vite.config.js # Vite配置
└── package.json # 项目配置
```
### 开发命令
```bash
# 开发模式
npm run dev:frontend
# 构建生产版本
npm run build:frontend
# 预览生产版本
npm run preview:frontend
```
### 环境配置
前端会自动代理API请求到后端
```javascript
// vite.config.js
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
```
## 安全特性
### 前端安全
-**JWT令牌管理** - 自动存储和刷新
-**路由保护** - 私有路由验证
-**表单验证** - 客户端和服务器端验证
-**错误处理** - 友好的错误提示
-**CORS配置** - 跨域请求处理
### 用户体验
-**响应式设计** - 移动端适配
-**加载状态** - 优雅的加载动画
-**错误提示** - 清晰的错误信息
-**表单验证** - 实时验证反馈
-**自动跳转** - 智能路由管理
## 数据库结构
### 用户表 (users)
```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### OAuth客户端表 (oauth_clients)
```sql
CREATE TABLE oauth_clients (
id SERIAL PRIMARY KEY,
client_id VARCHAR(100) UNIQUE NOT NULL,
client_secret VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
redirect_uris TEXT[] NOT NULL,
scopes TEXT[] DEFAULT ARRAY['read', 'write'],
user_id INTEGER NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### OAuth授权码表 (oauth_auth_codes)
```sql
CREATE TABLE oauth_auth_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(255) UNIQUE NOT NULL,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
redirect_uri VARCHAR(255) NOT NULL,
scopes TEXT[] NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### OAuth访问令牌表 (oauth_access_tokens)
```sql
CREATE TABLE oauth_access_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
scopes TEXT[] NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### OAuth刷新令牌表 (oauth_refresh_tokens)
```sql
CREATE TABLE oauth_refresh_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
access_token_id INTEGER REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
is_revoked BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## 安全特性
### 🔒 令牌安全
- 访问令牌有效期1小时
- 刷新令牌有效期30天
- 授权码有效期10分钟
- 自动清理过期令牌
### 🛡️ 客户端安全
- 客户端密钥加密存储
- 重定向URI验证
- 客户端所有权验证
- 支持客户端密钥重置
### 🔐 认证安全
- JWT令牌认证
- 密码bcrypt加密
- 输入验证和清理
- CORS配置
## 测试
### 运行OAuth测试
```bash
npm run test:oauth
```
### 运行基础API测试
```bash
npm run test
```
## 生产环境建议
### 1. 安全配置
- 使用强密码策略
- 启用HTTPS
- 配置CORS策略
- 设置适当的令牌过期时间
### 2. 性能优化
- 使用Redis缓存令牌
- 数据库连接池优化
- 定期清理过期数据
### 3. 监控和日志
- 添加请求日志
- 监控令牌使用情况
- 错误追踪和告警
### 4. 扩展功能
- 支持多种授权类型
- 添加OAuth客户端应用管理界面
- 实现OAuth 2.1标准
- 支持OpenID Connect
## 许可证
MIT License

24
config/database.js Normal file
View File

@ -0,0 +1,24 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'auth_db',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'your_password',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// 测试数据库连接
pool.on('connect', () => {
console.log('数据库连接成功');
});
pool.on('error', (err) => {
console.error('数据库连接错误:', err);
});
module.exports = pool;

View File

@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OAuth客户端示例</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
button {
background-color: #007bff;
color: white;
padding: 12px 24px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.result {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
white-space: pre-wrap;
font-family: monospace;
font-size: 12px;
}
.success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.info {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.step {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.step h3 {
margin-top: 0;
color: #333;
}
</style>
</head>
<body>
<div class="container">
<h1>OAuth 2.0 客户端示例</h1>
<p>这个页面演示了如何使用我们的OAuth提供商进行身份验证。</p>
<div class="step">
<h3>步骤 1: 配置OAuth客户端</h3>
<div class="form-group">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" placeholder="输入你的OAuth客户端ID">
</div>
<div class="form-group">
<label for="clientSecret">客户端密钥:</label>
<input type="text" id="clientSecret" placeholder="输入你的OAuth客户端密钥">
</div>
<div class="form-group">
<label for="redirectUri">重定向URI:</label>
<input type="text" id="redirectUri" value="http://localhost:3001/callback" readonly>
</div>
<button onclick="testOAuthFlow()">开始OAuth流程</button>
</div>
<div class="step">
<h3>步骤 2: 获取授权码</h3>
<p>点击上面的按钮开始OAuth授权流程。你需要先登录到OAuth提供商。</p>
<div id="authResult" class="result" style="display: none;"></div>
</div>
<div class="step">
<h3>步骤 3: 交换访问令牌</h3>
<button onclick="exchangeToken()" id="exchangeBtn" disabled>交换访问令牌</button>
<div id="tokenResult" class="result" style="display: none;"></div>
</div>
<div class="step">
<h3>步骤 4: 获取用户信息</h3>
<button onclick="getUserInfo()" id="userInfoBtn" disabled>获取用户信息</button>
<div id="userInfoResult" class="result" style="display: none;"></div>
</div>
<div class="step">
<h3>步骤 5: 撤销令牌</h3>
<button onclick="revokeToken()" id="revokeBtn" disabled>撤销访问令牌</button>
<div id="revokeResult" class="result" style="display: none;"></div>
</div>
</div>
<script>
let authCode = '';
let accessToken = '';
let refreshToken = '';
async function testOAuthFlow() {
const clientId = document.getElementById('clientId').value;
const redirectUri = document.getElementById('redirectUri').value;
if (!clientId) {
showResult('authResult', '请输入客户端ID', 'error');
return;
}
try {
// 构建授权URL
const authUrl = new URL('http://localhost:3000/api/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('scope', 'read write');
authUrl.searchParams.set('state', 'test_state_' + Date.now());
showResult('authResult', `正在重定向到授权页面...\n${authUrl.toString()}`, 'info');
// 在实际应用中,这里会重定向到授权页面
// 为了演示,我们模拟一个授权码
authCode = 'demo_auth_code_' + Date.now();
showResult('authResult', `模拟授权成功!\n授权码: ${authCode}`, 'success');
document.getElementById('exchangeBtn').disabled = false;
} catch (error) {
showResult('authResult', `授权失败: ${error.message}`, 'error');
}
}
async function exchangeToken() {
const clientId = document.getElementById('clientId').value;
const clientSecret = document.getElementById('clientSecret').value;
const redirectUri = document.getElementById('redirectUri').value;
if (!clientId || !clientSecret) {
showResult('tokenResult', '请输入客户端ID和密钥', 'error');
return;
}
try {
const response = await fetch('http://localhost:3000/api/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: authCode,
redirect_uri: redirectUri
})
});
const data = await response.json();
if (data.success) {
accessToken = data.data.access_token;
refreshToken = data.data.refresh_token;
showResult('tokenResult', `令牌交换成功!\n访问令牌: ${accessToken.substring(0, 20)}...\n刷新令牌: ${refreshToken.substring(0, 20)}...`, 'success');
document.getElementById('userInfoBtn').disabled = false;
document.getElementById('revokeBtn').disabled = false;
} else {
showResult('tokenResult', `令牌交换失败: ${data.message}`, 'error');
}
} catch (error) {
showResult('tokenResult', `请求失败: ${error.message}`, 'error');
}
}
async function getUserInfo() {
try {
const response = await fetch('http://localhost:3000/api/oauth/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const data = await response.json();
if (data.success) {
showResult('userInfoResult', `用户信息获取成功!\n${JSON.stringify(data.data, null, 2)}`, 'success');
} else {
showResult('userInfoResult', `获取用户信息失败: ${data.message}`, 'error');
}
} catch (error) {
showResult('userInfoResult', `请求失败: ${error.message}`, 'error');
}
}
async function revokeToken() {
const clientId = document.getElementById('clientId').value;
const clientSecret = document.getElementById('clientSecret').value;
try {
const response = await fetch('http://localhost:3000/api/oauth/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: accessToken,
client_id: clientId,
client_secret: clientSecret
})
});
const data = await response.json();
if (data.success) {
showResult('revokeResult', '令牌撤销成功!', 'success');
accessToken = '';
refreshToken = '';
document.getElementById('userInfoBtn').disabled = true;
document.getElementById('revokeBtn').disabled = true;
} else {
showResult('revokeResult', `令牌撤销失败: ${data.message}`, 'error');
}
} catch (error) {
showResult('revokeResult', `请求失败: ${error.message}`, 'error');
}
}
function showResult(elementId, message, type) {
const element = document.getElementById(elementId);
element.textContent = message;
element.className = `result ${type}`;
element.style.display = 'block';
}
</script>
</body>
</html>

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OAuth认证系统</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

134
index.js Normal file
View File

@ -0,0 +1,134 @@
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const User = require('./models/User');
const OAuthClient = require('./models/OAuthClient');
const OAuthToken = require('./models/OAuthToken');
const authRoutes = require('./routes/auth');
const oauthRoutes = require('./routes/oauth');
const oauthClientRoutes = require('./routes/oauth-clients');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 请求日志中间件
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// 路由
app.use('/api/auth', authRoutes);
app.use('/api/oauth', oauthRoutes);
app.use('/api/oauth', oauthClientRoutes);
// 根路由
app.get('/', (req, res) => {
res.json({
success: true,
message: '认证和OAuth API服务器运行中',
endpoints: {
auth: {
register: 'POST /api/auth/register',
login: 'POST /api/auth/login',
profile: 'GET /api/auth/profile',
test: 'GET /api/auth/test'
},
oauth: {
authorize: 'GET /api/oauth/authorize',
token: 'POST /api/oauth/token',
revoke: 'POST /api/oauth/revoke',
userinfo: 'GET /api/oauth/userinfo',
tokeninfo: 'GET /api/oauth/tokeninfo'
},
oauth_clients: {
create: 'POST /api/oauth/clients',
list: 'GET /api/oauth/clients',
detail: 'GET /api/oauth/clients/:clientId',
delete: 'DELETE /api/oauth/clients/:clientId',
stats: 'GET /api/oauth/clients/:clientId/stats'
}
}
});
});
// 健康检查
app.get('/health', (req, res) => {
res.json({
success: true,
message: '服务器运行正常',
timestamp: new Date().toISOString()
});
});
// OAuth发现端点
app.get('/.well-known/oauth-authorization-server', (req, res) => {
res.json({
issuer: `${req.protocol}://${req.get('host')}`,
authorization_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/authorize`,
token_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/token`,
revocation_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/revoke`,
userinfo_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/userinfo`,
tokeninfo_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/tokeninfo`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
scopes_supported: ['read', 'write'],
code_challenge_methods_supported: []
});
});
// 404处理
app.use('*', (req, res) => {
res.status(404).json({
success: false,
message: '路由不存在'
});
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error('服务器错误:', err);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
});
// 启动服务器
const startServer = async () => {
try {
// 创建所有数据库表
await User.createTable();
await OAuthClient.createTable();
await OAuthClient.createAuthCodeTable();
await OAuthClient.createAccessTokenTable();
await OAuthClient.createRefreshTokenTable();
// 设置定时清理过期令牌
setInterval(async () => {
try {
await OAuthToken.cleanupExpiredTokens();
} catch (error) {
console.error('清理过期令牌失败:', error);
}
}, 60 * 60 * 1000); // 每小时清理一次
app.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT}`);
console.log(`API文档: http://localhost:${PORT}`);
console.log(`OAuth发现端点: http://localhost:${PORT}/.well-known/oauth-authorization-server`);
});
} catch (error) {
console.error('启动服务器失败:', error);
process.exit(1);
}
};
startServer();

39
middleware/auth.js Normal file
View File

@ -0,0 +1,39 @@
const jwt = require('jsonwebtoken');
// 生成JWT token
const generateToken = (userId, username) => {
return jwt.sign(
{ userId, username },
process.env.JWT_SECRET || 'your_jwt_secret_key_here',
{ expiresIn: '24h' }
);
};
// 验证JWT token中间件
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
message: '访问令牌缺失'
});
}
jwt.verify(token, process.env.JWT_SECRET || 'your_jwt_secret_key_here', (err, user) => {
if (err) {
return res.status(403).json({
success: false,
message: '访问令牌无效'
});
}
req.user = user;
next();
});
};
module.exports = {
generateToken,
authenticateToken
};

118
middleware/oauth.js Normal file
View File

@ -0,0 +1,118 @@
const OAuthToken = require('../models/OAuthToken');
// OAuth访问令牌验证中间件
const authenticateOAuthToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
message: '访问令牌缺失'
});
}
try {
const tokenData = await OAuthToken.validateAccessToken(token);
if (!tokenData) {
return res.status(401).json({
success: false,
message: '访问令牌无效或已过期'
});
}
req.oauth = {
token: tokenData.token,
clientId: tokenData.client_id,
userId: tokenData.user_id,
scopes: tokenData.scopes,
username: tokenData.username,
email: tokenData.email
};
next();
} catch (error) {
console.error('OAuth令牌验证失败:', error);
return res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 检查OAuth权限范围
const requireScope = (requiredScope) => {
return (req, res, next) => {
if (!req.oauth) {
return res.status(401).json({
success: false,
message: '需要OAuth认证'
});
}
if (!req.oauth.scopes.includes(requiredScope)) {
return res.status(403).json({
success: false,
message: `需要权限范围: ${requiredScope}`
});
}
next();
};
};
// 检查多个权限范围(任一即可)
const requireAnyScope = (requiredScopes) => {
return (req, res, next) => {
if (!req.oauth) {
return res.status(401).json({
success: false,
message: '需要OAuth认证'
});
}
const hasAnyScope = requiredScopes.some(scope =>
req.oauth.scopes.includes(scope)
);
if (!hasAnyScope) {
return res.status(403).json({
success: false,
message: `需要权限范围: ${requiredScopes.join(' 或 ')}`
});
}
next();
};
};
// 检查所有权限范围
const requireAllScopes = (requiredScopes) => {
return (req, res, next) => {
if (!req.oauth) {
return res.status(401).json({
success: false,
message: '需要OAuth认证'
});
}
const hasAllScopes = requiredScopes.every(scope =>
req.oauth.scopes.includes(scope)
);
if (!hasAllScopes) {
return res.status(403).json({
success: false,
message: `需要所有权限范围: ${requiredScopes.join(', ')}`
});
}
next();
};
};
module.exports = {
authenticateOAuthToken,
requireScope,
requireAnyScope,
requireAllScopes
};

56
middleware/validation.js Normal file
View File

@ -0,0 +1,56 @@
const { body, validationResult } = require('express-validator');
// 注册验证规则
const registerValidation = [
body('username')
.isLength({ min: 3, max: 50 })
.withMessage('用户名长度必须在3-50个字符之间')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('用户名只能包含字母、数字和下划线'),
body('email')
.isEmail()
.withMessage('请输入有效的邮箱地址')
.normalizeEmail(),
body('password')
.isLength({ min: 6 })
.withMessage('密码长度至少6个字符')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('密码必须包含至少一个小写字母、一个大写字母和一个数字')
];
// 登录验证规则
const loginValidation = [
body('username')
.notEmpty()
.withMessage('用户名不能为空'),
body('password')
.notEmpty()
.withMessage('密码不能为空')
];
// 验证结果处理中间件
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
// console.log(req.body);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: '输入验证失败',
errors: errors.array().map(error => ({
field: error.path,
message: error.msg
}))
});
}
next();
};
module.exports = {
registerValidation,
loginValidation,
handleValidationErrors
};

206
models/OAuthClient.js Normal file
View File

@ -0,0 +1,206 @@
const pool = require('../config/database');
const crypto = require('crypto');
class OAuthClient {
// 创建OAuth客户端表
static async createTable() {
const query = `
CREATE TABLE IF NOT EXISTS oauth_clients (
id SERIAL PRIMARY KEY,
client_id VARCHAR(100) UNIQUE NOT NULL,
client_secret VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
redirect_uris TEXT[] NOT NULL,
scopes TEXT[] DEFAULT ARRAY['read', 'write'],
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
try {
await pool.query(query);
console.log('OAuth客户端表创建成功');
} catch (error) {
console.error('创建OAuth客户端表失败:', error);
throw error;
}
}
// 创建授权码表
static async createAuthCodeTable() {
const query = `
CREATE TABLE IF NOT EXISTS oauth_auth_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(100) UNIQUE NOT NULL,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
redirect_uri VARCHAR(255) NOT NULL,
scopes TEXT[],
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
try {
await pool.query(query);
console.log('OAuth授权码表创建成功');
} catch (error) {
console.error('创建OAuth授权码表失败:', error);
throw error;
}
}
// 创建访问令牌表
static async createAccessTokenTable() {
const query = `
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
scopes TEXT[],
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
try {
await pool.query(query);
console.log('OAuth访问令牌表创建成功');
} catch (error) {
console.error('创建OAuth访问令牌表失败:', error);
throw error;
}
}
// 创建刷新令牌表
static async createRefreshTokenTable() {
const query = `
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
access_token_id INTEGER REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
client_id VARCHAR(100) NOT NULL,
user_id INTEGER NOT NULL,
is_revoked BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
try {
await pool.query(query);
console.log('OAuth刷新令牌表创建成功');
} catch (error) {
console.error('创建OAuth刷新令牌表失败:', error);
throw error;
}
}
// 生成客户端ID和密钥
static generateClientCredentials() {
const clientId = crypto.randomBytes(32).toString('hex');
const clientSecret = crypto.randomBytes(64).toString('hex');
return { clientId, clientSecret };
}
// 创建新的OAuth客户端
static async create(clientData) {
const { name, description, redirectUris, scopes, userId } = clientData;
const { clientId, clientSecret } = this.generateClientCredentials();
const query = `
INSERT INTO oauth_clients (client_id, client_secret, name, description, redirect_uris, scopes, user_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, client_id, client_secret, name, description, redirect_uris, scopes, created_at
`;
try {
const result = await pool.query(query, [
clientId,
clientSecret,
name,
description,
redirectUris,
scopes || ['read', 'write'],
userId
]);
return result.rows[0];
} catch (error) {
console.error('创建OAuth客户端失败:', error);
throw error;
}
}
// 重置客户端密钥
static async resetClientSecret(clientId) {
const newSecret = crypto.randomBytes(64).toString('hex');
const query = 'UPDATE oauth_clients SET client_secret = $1 WHERE client_id = $2 AND is_active = true';
try {
const result = await pool.query(query, [newSecret, clientId]);
if (result.rowCount === 0) {
throw new Error('客户端不存在或已禁用');
}
return newSecret;
} catch (error) {
console.error('重置客户端密钥失败:', error);
throw error;
}
}
// 根据客户端ID查找客户端
static async findByClientId(clientId) {
const query = 'SELECT * FROM oauth_clients WHERE client_id = $1 AND is_active = true';
try {
const result = await pool.query(query, [clientId]);
return result.rows[0];
} catch (error) {
console.error('查找OAuth客户端失败:', error);
throw error;
}
}
// 验证客户端密钥
static async validateClient(clientId, clientSecret) {
const client = await this.findByClientId(clientId);
if (!client) return false;
return client.client_secret === clientSecret;
}
// 验证重定向URI
static async validateRedirectUri(clientId, redirectUri) {
const client = await this.findByClientId(clientId);
if (!client) return false;
return client.redirect_uris.includes(redirectUri);
}
// 获取用户的所有客户端
static async findByUserId(userId) {
const query = 'SELECT id, client_id, name, description, redirect_uris, scopes, created_at FROM oauth_clients WHERE user_id = $1 AND is_active = true';
try {
const result = await pool.query(query, [userId]);
return result.rows;
} catch (error) {
console.error('查找用户客户端失败:', error);
throw error;
}
}
// 删除客户端
static async delete(clientId, userId) {
const query = 'UPDATE oauth_clients SET is_active = false WHERE client_id = $1 AND user_id = $2';
try {
const result = await pool.query(query, [clientId, userId]);
return result.rowCount > 0;
} catch (error) {
console.error('删除OAuth客户端失败:', error);
throw error;
}
}
}
module.exports = OAuthClient;

207
models/OAuthToken.js Normal file
View File

@ -0,0 +1,207 @@
const pool = require('../config/database');
const crypto = require('crypto');
class OAuthToken {
// 生成授权码
static generateAuthCode() {
return crypto.randomBytes(32).toString('hex');
}
// 生成访问令牌
static generateAccessToken() {
return crypto.randomBytes(64).toString('hex');
}
// 生成刷新令牌
static generateRefreshToken() {
return crypto.randomBytes(64).toString('hex');
}
// 创建授权码
static async createAuthCode(authCodeData) {
const { code, clientId, userId, redirectUri, scopes } = authCodeData;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10分钟过期
const query = `
INSERT INTO oauth_auth_codes (code, client_id, user_id, redirect_uri, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
try {
const result = await pool.query(query, [code, clientId, userId, redirectUri, scopes, expiresAt]);
return result.rows[0];
} catch (error) {
console.error('创建授权码失败:', error);
throw error;
}
}
// 验证授权码
static async validateAuthCode(code, clientId, redirectUri) {
const query = `
SELECT * FROM oauth_auth_codes
WHERE code = $1 AND client_id = $2 AND redirect_uri = $3 AND expires_at > NOW()
`;
try {
const result = await pool.query(query, [code, clientId, redirectUri]);
// console.log(result.rows[0]);
// console.log(code, clientId, redirectUri);
// // console.log();
return result.rows[0];
} catch (error) {
console.error('验证授权码失败:', error);
throw error;
}
}
// 删除授权码
static async deleteAuthCode(code) {
const query = 'DELETE FROM oauth_auth_codes WHERE code = $1';
try {
await pool.query(query, [code]);
} catch (error) {
console.error('删除授权码失败:', error);
throw error;
}
}
// 创建访问令牌
static async createAccessToken(accessTokenData) {
const { token, clientId, userId, scopes } = accessTokenData;
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1小时过期
const query = `
INSERT INTO oauth_access_tokens (token, client_id, user_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`;
try {
const result = await pool.query(query, [token, clientId, userId, scopes, expiresAt]);
return result.rows[0];
} catch (error) {
console.error('创建访问令牌失败:', error);
throw error;
}
}
// 创建刷新令牌
static async createRefreshToken(refreshTokenData) {
const { token, accessTokenId, clientId, userId } = refreshTokenData;
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30天过期
const query = `
INSERT INTO oauth_refresh_tokens (token, access_token_id, client_id, user_id, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`;
try {
const result = await pool.query(query, [token, accessTokenId, clientId, userId, expiresAt]);
return result.rows[0];
} catch (error) {
console.error('创建刷新令牌失败:', error);
throw error;
}
}
// 验证访问令牌
static async validateAccessToken(token) {
const query = `
SELECT oat.*, u.username, u.email
FROM oauth_access_tokens oat
JOIN users u ON oat.user_id = u.id
WHERE oat.token = $1 AND oat.expires_at > NOW()
`;
try {
const result = await pool.query(query, [token]);
return result.rows[0];
} catch (error) {
console.error('验证访问令牌失败:', error);
throw error;
}
}
// 撤销刷新令牌
static async revokeRefreshToken(token) {
const query = 'UPDATE oauth_refresh_tokens SET is_revoked = true WHERE token = $1';
try {
await pool.query(query, [token]);
} catch (error) {
console.error('撤销刷新令牌失败:', error);
throw error;
}
}
// 验证刷新令牌
static async validateRefreshToken(token, clientId) {
const query = `
SELECT rt.*, at.scopes
FROM oauth_refresh_tokens rt
JOIN oauth_access_tokens at ON rt.access_token_id = at.id
WHERE rt.token = $1 AND rt.client_id = $2 AND rt.is_revoked = false AND rt.expires_at > NOW()
`;
try {
const result = await pool.query(query, [token, clientId]);
return result.rows[0];
} catch (error) {
console.error('验证刷新令牌失败:', error);
return null;
}
}
// 撤销访问令牌
static async revokeAccessToken(token) {
const query = 'DELETE FROM oauth_access_tokens WHERE token = $1';
try {
const result = await pool.query(query, [token]);
return result.rowCount > 0;
} catch (error) {
console.error('撤销访问令牌失败:', error);
throw error;
}
}
// 清理过期令牌(改进版)
static async cleanupExpiredTokens() {
try {
// 清理过期的访问令牌
await pool.query('DELETE FROM oauth_access_tokens WHERE expires_at < NOW()');
// 清理过期的刷新令牌
await pool.query('DELETE FROM oauth_refresh_tokens WHERE expires_at < NOW()');
// 清理过期的授权码
await pool.query('DELETE FROM oauth_auth_codes WHERE expires_at < NOW()');
console.log('已清理过期令牌');
} catch (error) {
console.error('清理过期令牌失败:', error);
throw error;
}
}
// 获取用户的活跃令牌
static async getActiveTokensByUserId(userId) {
const query = `
SELECT oat.token, oat.client_id, oat.scopes, oat.expires_at, oc.name as client_name
FROM oauth_access_tokens oat
JOIN oauth_clients oc ON oat.client_id = oc.client_id
WHERE oat.user_id = $1 AND oat.expires_at > NOW()
`;
try {
const result = await pool.query(query, [userId]);
return result.rows;
} catch (error) {
console.error('获取用户活跃令牌失败:', error);
throw error;
}
}
}
module.exports = OAuthToken;

92
models/User.js Normal file
View File

@ -0,0 +1,92 @@
const pool = require('../config/database');
const bcrypt = require('bcryptjs');
class User {
// 创建用户表
static async createTable() {
const query = `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
try {
await pool.query(query);
console.log('用户表创建成功');
} catch (error) {
console.error('创建用户表失败:', error);
throw error;
}
}
// 根据用户名查找用户
static async findByUsername(username) {
const query = 'SELECT * FROM users WHERE username = $1';
try {
const result = await pool.query(query, [username]);
return result.rows[0];
} catch (error) {
console.error('查找用户失败:', error);
throw error;
}
}
// 根据邮箱查找用户
static async findByEmail(email) {
const query = 'SELECT * FROM users WHERE email = $1';
try {
const result = await pool.query(query, [email]);
return result.rows[0];
} catch (error) {
console.error('查找用户失败:', error);
throw error;
}
}
// 创建新用户
static async create(userData) {
const { username, email, password } = userData;
// 加密密码
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
const query = `
INSERT INTO users (username, email, password)
VALUES ($1, $2, $3)
RETURNING id, username, email, created_at
`;
try {
const result = await pool.query(query, [username, email, hashedPassword]);
return result.rows[0];
} catch (error) {
console.error('创建用户失败:', error);
throw error;
}
}
// 验证密码
static async verifyPassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword);
}
// 获取所有用户(仅用于测试)
static async findAll() {
const query = 'SELECT id, username, email, created_at FROM users';
try {
const result = await pool.query(query);
return result.rows;
} catch (error) {
console.error('获取用户列表失败:', error);
throw error;
}
}
}
module.exports = User;

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "auth-api",
"version": "1.0.0",
"description": "登录和注册API系统",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "node test-api.js",
"test:oauth": "node test-oauth.js",
"dev:frontend": "vite",
"build:frontend": "vite build",
"preview:frontend": "vite preview"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express-validator": "^7.0.1"
},
"devDependencies": {
"nodemon": "^3.0.1",
"axios": "^1.5.0",
"@vitejs/plugin-react": "^4.0.0",
"vite": "^4.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@mui/material": "^5.14.0",
"@mui/icons-material": "^5.14.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"react-router-dom": "^6.15.0"
},
"keywords": ["auth", "api", "postgresql", "login", "register", "oauth"],
"author": "",
"license": "MIT"
}

2486
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

245
public/index.html Normal file
View File

@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OAuth认证系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.auth-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.btn-primary {
background: linear-gradient(45deg, #667eea, #764ba2);
border: none;
}
.btn-primary:hover {
background: linear-gradient(45deg, #5a6fd8, #6a4190);
}
</style>
</head>
<body>
<div class="container">
<div class="row justify-content-center align-items-center min-vh-100">
<div class="col-md-6 col-lg-4">
<div class="auth-card p-4">
<div class="text-center mb-4">
<i class="fas fa-user-circle fa-3x text-primary mb-3"></i>
<h2 id="page-title">登录</h2>
<p class="text-muted" id="page-subtitle">欢迎回来,请登录您的账户</p>
</div>
<div id="alert-container"></div>
<form id="auth-form">
<div class="mb-3" id="username-field">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3" id="email-field" style="display: none;">
<label for="email" class="form-label">邮箱</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<div class="input-group">
<input type="password" class="form-control" id="password" name="password" required>
<button class="btn btn-outline-secondary" type="button" id="toggle-password">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="mb-3" id="confirm-password-field" style="display: none;">
<label for="confirm-password" class="form-label">确认密码</label>
<div class="input-group">
<input type="password" class="form-control" id="confirm-password" name="confirm-password">
<button class="btn btn-outline-secondary" type="button" id="toggle-confirm-password">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3" id="submit-btn">
<span id="submit-text">登录</span>
<span id="loading-spinner" style="display: none;">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
<div class="text-center">
<p class="mb-0">
<span id="switch-text">还没有账户?</span>
<a href="#" id="switch-link">立即注册</a>
</p>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const API_BASE = 'http://localhost:3000/api';
let isLoginMode = true;
// 切换密码可见性
document.getElementById('toggle-password').addEventListener('click', function() {
const passwordInput = document.getElementById('password');
const icon = this.querySelector('i');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
icon.className = 'fas fa-eye';
}
});
document.getElementById('toggle-confirm-password').addEventListener('click', function() {
const confirmPasswordInput = document.getElementById('confirm-password');
const icon = this.querySelector('i');
if (confirmPasswordInput.type === 'password') {
confirmPasswordInput.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
confirmPasswordInput.type = 'password';
icon.className = 'fas fa-eye';
}
});
// 切换登录/注册模式
document.getElementById('switch-link').addEventListener('click', function(e) {
e.preventDefault();
toggleMode();
});
function toggleMode() {
isLoginMode = !isLoginMode;
if (isLoginMode) {
// 切换到登录模式
document.getElementById('page-title').textContent = '登录';
document.getElementById('page-subtitle').textContent = '欢迎回来,请登录您的账户';
document.getElementById('submit-text').textContent = '登录';
document.getElementById('switch-text').textContent = '还没有账户?';
document.getElementById('switch-link').textContent = '立即注册';
document.getElementById('email-field').style.display = 'none';
document.getElementById('confirm-password-field').style.display = 'none';
document.getElementById('email').required = false;
document.getElementById('confirm-password').required = false;
} else {
// 切换到注册模式
document.getElementById('page-title').textContent = '注册';
document.getElementById('page-subtitle').textContent = '创建您的账户';
document.getElementById('submit-text').textContent = '注册';
document.getElementById('switch-text').textContent = '已有账户?';
document.getElementById('switch-link').textContent = '立即登录';
document.getElementById('email-field').style.display = 'block';
document.getElementById('confirm-password-field').style.display = 'block';
document.getElementById('email').required = true;
document.getElementById('confirm-password').required = true;
}
clearForm();
hideAlert();
}
// 表单提交
document.getElementById('auth-form').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const submitText = document.getElementById('submit-text');
const loadingSpinner = document.getElementById('loading-spinner');
submitBtn.disabled = true;
submitText.style.display = 'none';
loadingSpinner.style.display = 'inline';
try {
const formData = new FormData(this);
const data = Object.fromEntries(formData);
if (!isLoginMode) {
// 注册模式
if (data.password !== data['confirm-password']) {
showAlert('两次输入的密码不一致', 'danger');
return;
}
if (data.password.length < 6) {
showAlert('密码长度至少6位', 'danger');
return;
}
}
const endpoint = isLoginMode ? '/auth/login' : '/auth/register';
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: data.username,
password: data.password,
...(isLoginMode ? {} : { email: data.email })
})
});
const result = await response.json();
if (result.success) {
localStorage.setItem('token', result.data.token);
showAlert('操作成功!正在跳转...', 'success');
setTimeout(() => {
window.location.href = '/dashboard.html';
}, 1000);
} else {
showAlert(result.message || '操作失败', 'danger');
}
} catch (error) {
showAlert('网络错误,请稍后重试', 'danger');
} finally {
submitBtn.disabled = false;
submitText.style.display = 'inline';
loadingSpinner.style.display = 'none';
}
});
function showAlert(message, type) {
const alertContainer = document.getElementById('alert-container');
alertContainer.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
}
function hideAlert() {
document.getElementById('alert-container').innerHTML = '';
}
function clearForm() {
document.getElementById('auth-form').reset();
}
// 检查是否已登录
const token = localStorage.getItem('token');
if (token) {
window.location.href = '/dashboard.html';
}
</script>
</body>
</html>

162
routes/auth.js Normal file
View File

@ -0,0 +1,162 @@
const express = require('express');
const User = require('../models/User');
const { generateToken, authenticateToken } = require('../middleware/auth');
const {
registerValidation,
loginValidation,
handleValidationErrors
} = require('../middleware/validation');
const router = express.Router();
// 注册路由
router.post('/register', registerValidation, handleValidationErrors, async (req, res) => {
try {
const { username, email, password } = req.body;
// 检查用户名是否已存在
const existingUserByUsername = await User.findByUsername(username);
if (existingUserByUsername) {
return res.status(400).json({
success: false,
message: '用户名已存在'
});
}
// 检查邮箱是否已存在
const existingUserByEmail = await User.findByEmail(email);
if (existingUserByEmail) {
return res.status(400).json({
success: false,
message: '邮箱已被注册'
});
}
// 创建新用户
const newUser = await User.create({ username, email, password });
// 生成JWT token
const token = generateToken(newUser.id, newUser.username);
res.status(201).json({
success: true,
message: '注册成功',
data: {
user: {
id: newUser.id,
username: newUser.username,
email: newUser.email,
created_at: newUser.created_at
},
token
}
});
} catch (error) {
console.error('注册失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 登录路由
router.post('/login', loginValidation, handleValidationErrors, async (req, res) => {
try {
const { username, password } = req.body;
// 查找用户(支持用户名或邮箱登录)
let user = await User.findByUsername(username);
if (!user) {
// 如果不是用户名,尝试用邮箱查找
user = await User.findByEmail(username);
}
if (!user) {
return res.status(401).json({
success: false,
message: '用户名或密码错误'
});
}
// 验证密码
const isPasswordValid = await User.verifyPassword(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: '用户名或密码错误'
});
}
// 生成JWT token
const token = generateToken(user.id, user.username);
res.json({
success: true,
message: '登录成功',
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
},
token
}
});
} catch (error) {
console.error('登录失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 获取当前用户信息(需要认证)
router.get('/profile', authenticateToken, async (req, res) => {
try {
const user = await User.findByUsername(req.user.username);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
res.json({
success: true,
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
}
}
});
} catch (error) {
console.error('获取用户信息失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 测试路由(需要认证)
router.get('/test', authenticateToken, (req, res) => {
res.json({
success: true,
message: '认证成功',
data: {
user: req.user
}
});
});
module.exports = router;

349
routes/oauth-clients.js Normal file
View File

@ -0,0 +1,349 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const OAuthClient = require('../models/OAuthClient');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
// 验证中间件
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: '输入验证失败',
errors: errors.array().map(error => ({
field: error.path,
message: error.msg
}))
});
}
next();
};
// 自定义URL验证函数
const isValidRedirectUri = (value) => {
console.log('验证URL:', value);
try {
const url = new URL(value);
console.log('URL解析结果:', {
protocol: url.protocol,
hostname: url.hostname,
pathname: url.pathname,
isValid: (url.protocol === 'http:' || url.protocol === 'https:') &&
url.hostname &&
url.pathname
});
// 允许http和https协议包括localhost
return (url.protocol === 'http:' || url.protocol === 'https:') &&
url.hostname &&
url.pathname;
} catch (error) {
console.log('URL验证失败:', value, error.message);
return false;
}
};
// 创建OAuth客户端验证规则
const createClientValidation = [
body('name')
.isLength({ min: 1, max: 100 })
.withMessage('应用名称长度必须在1-100个字符之间'),
body('description')
.optional()
.isLength({ max: 500 })
.withMessage('描述长度不能超过500个字符'),
body('redirect_uris')
.isArray({ min: 1 })
.withMessage('至少需要一个重定向URI'),
body('scopes')
.optional()
.isArray()
.withMessage('权限范围必须是数组格式')
];
// 自定义验证中间件
const validateRedirectUris = (req, res, next) => {
const { redirect_uris } = req.body;
if (!Array.isArray(redirect_uris)) {
return res.status(400).json({
success: false,
message: 'redirect_uris必须是数组'
});
}
for (let i = 0; i < redirect_uris.length; i++) {
if (!isValidRedirectUri(redirect_uris[i])) {
return res.status(400).json({
success: false,
message: '输入验证失败',
errors: [{
field: `redirect_uris[${i}]`,
message: '重定向URI必须是有效的URL'
}]
});
}
}
next();
};
// 1. 创建OAuth客户端
router.post('/clients', authenticateToken, createClientValidation, validateRedirectUris, handleValidationErrors, async (req, res) => {
try {
const { name, description, redirect_uris, scopes } = req.body;
const userId = req.user.userId;
const clientData = {
name,
description: description || '',
redirectUris: redirect_uris,
scopes: scopes || ['read', 'write'],
userId
};
const newClient = await OAuthClient.create(clientData);
res.status(201).json({
success: true,
message: 'OAuth客户端创建成功',
data: {
client_id: newClient.client_id,
client_secret: newClient.client_secret, // 返回客户端密钥
name: newClient.name,
description: newClient.description,
redirect_uris: newClient.redirect_uris,
scopes: newClient.scopes,
created_at: newClient.created_at
}
});
} catch (error) {
console.error('创建OAuth客户端失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 2. 获取用户的所有OAuth客户端
router.get('/clients', authenticateToken, async (req, res) => {
try {
const userId = req.user.userId;
const clients = await OAuthClient.findByUserId(userId);
res.json({
success: true,
data: {
clients: clients.map(client => ({
client_id: client.client_id,
name: client.name,
description: client.description,
redirect_uris: client.redirect_uris,
scopes: client.scopes,
created_at: client.created_at
}))
}
});
} catch (error) {
console.error('获取OAuth客户端失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 3. 获取特定OAuth客户端详情
router.get('/clients/:clientId', authenticateToken, async (req, res) => {
try {
const { clientId } = req.params;
const userId = req.user.userId;
const client = await OAuthClient.findByClientId(clientId);
if (!client) {
return res.status(404).json({
success: false,
message: 'OAuth客户端不存在'
});
}
// 检查是否是客户端所有者
if (client.user_id !== userId) {
return res.status(403).json({
success: false,
message: '无权访问此客户端'
});
}
res.json({
success: true,
data: {
client_id: client.client_id,
name: client.name,
description: client.description,
redirect_uris: client.redirect_uris,
scopes: client.scopes,
created_at: client.created_at
}
});
} catch (error) {
console.error('获取OAuth客户端详情失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 4. 删除OAuth客户端
router.delete('/clients/:clientId', authenticateToken, async (req, res) => {
try {
const { clientId } = req.params;
const userId = req.user.userId;
const deleted = await OAuthClient.delete(clientId, userId);
if (!deleted) {
return res.status(404).json({
success: false,
message: 'OAuth客户端不存在或无权删除'
});
}
res.json({
success: true,
message: 'OAuth客户端已删除'
});
} catch (error) {
console.error('删除OAuth客户端失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 4. 获取客户端密钥(仅限客户端所有者)
router.get('/clients/:clientId/secret', authenticateToken, async (req, res) => {
try {
const { clientId } = req.params;
const userId = req.user.userId;
const client = await OAuthClient.findByClientId(clientId);
if (!client) {
return res.status(404).json({
success: false,
message: 'OAuth客户端不存在'
});
}
// 检查是否是客户端所有者
if (client.user_id !== userId) {
return res.status(403).json({
success: false,
message: '无权访问此客户端'
});
}
res.json({
success: true,
data: {
client_id: client.client_id,
client_secret: client.client_secret
}
});
} catch (error) {
console.error('获取客户端密钥失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 5. 重置客户端密钥
router.post('/clients/:clientId/reset-secret', authenticateToken, async (req, res) => {
try {
const { clientId } = req.params;
const userId = req.user.userId;
const client = await OAuthClient.findByClientId(clientId);
if (!client) {
return res.status(404).json({
success: false,
message: 'OAuth客户端不存在'
});
}
// 检查是否是客户端所有者
if (client.user_id !== userId) {
return res.status(403).json({
success: false,
message: '无权操作此客户端'
});
}
const newSecret = await OAuthClient.resetClientSecret(clientId);
res.json({
success: true,
message: '客户端密钥重置成功',
data: {
client_id: clientId,
client_secret: newSecret
}
});
} catch (error) {
console.error('重置客户端密钥失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 5. 获取OAuth客户端统计信息
router.get('/clients/:clientId/stats', authenticateToken, async (req, res) => {
try {
const { clientId } = req.params;
const userId = req.user.userId;
const client = await OAuthClient.findByClientId(clientId);
if (!client || client.user_id !== userId) {
return res.status(404).json({
success: false,
message: 'OAuth客户端不存在'
});
}
// 这里可以添加更多统计信息,比如活跃令牌数量等
res.json({
success: true,
data: {
client_id: client.client_id,
name: client.name,
created_at: client.created_at,
// 可以添加更多统计信息
}
});
} catch (error) {
console.error('获取OAuth客户端统计失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
module.exports = router;

375
routes/oauth.js Normal file
View File

@ -0,0 +1,375 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const OAuthClient = require('../models/OAuthClient');
const OAuthToken = require('../models/OAuthToken');
const User = require('../models/User');
const { authenticateToken } = require('../middleware/auth');
const { authenticateOAuthToken, requireScope } = require('../middleware/oauth');
const router = express.Router();
// 验证中间件
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: '输入验证失败',
errors: errors.array().map(error => ({
field: error.path,
message: error.msg
}))
});
}
next();
};
// 1. 授权端点 - 用户授权第三方应用
router.get('/authorize', authenticateToken, async (req, res) => {
try {
const {
response_type,
client_id,
redirect_uri,
scope,
state
} = req.query;
// 验证必需参数
if (!response_type || !client_id || !redirect_uri) {
return res.status(400).json({
success: false,
message: '缺少必需参数'
});
}
// 验证response_type
if (response_type !== 'code') {
return res.status(400).json({
success: false,
message: '不支持的response_type'
});
}
// 验证客户端
const client = await OAuthClient.findByClientId(client_id);
if (!client) {
return res.status(400).json({
success: false,
message: '无效的客户端ID'
});
}
// 验证重定向URI
if (!client.redirect_uris.includes(redirect_uri)) {
return res.status(400).json({
success: false,
message: '无效的重定向URI'
});
}
// 获取用户信息通过JWT中间件已经验证
const user = await User.findByUsername(req.user.username);
if (!user) {
return res.status(401).json({
success: false,
message: '用户不存在'
});
}
// 生成授权码
const authCode = OAuthToken.generateAuthCode();
const scopes = scope ? scope.split(' ') : ['read', 'write'];
await OAuthToken.createAuthCode({
code: authCode,
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scopes: scopes
});
// 构建重定向URL
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', authCode);
if (state) {
redirectUrl.searchParams.set('state', state);
}
// 对于API测试直接返回授权码
// 对于生产环境应该重定向到redirectUrl
res.json({
success: true,
message: '授权成功',
data: {
redirect_url: redirectUrl.toString(),
code: authCode,
state: state
}
});
} catch (error) {
console.error('授权失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 2. 令牌端点 - 交换授权码获取访问令牌
router.post('/token', [
body('grant_type').notEmpty().withMessage('grant_type不能为空'),
body('client_id').notEmpty().withMessage('client_id不能为空'),
body('client_secret').notEmpty().withMessage('client_secret不能为空'),
handleValidationErrors
], async (req, res) => {
try {
const {
grant_type,
client_id,
client_secret,
code,
redirect_uri,
refresh_token,
code_verifier // PKCE支持
} = req.body;
// 验证客户端
const isValidClient = await OAuthClient.validateClient(client_id, client_secret);
if (!isValidClient) {
return res.status(401).json({
success: false,
message: '无效的客户端凭据'
});
}
if (grant_type === 'authorization_code') {
// 授权码流程
if (!code || !redirect_uri) {
return res.status(400).json({
success: false,
message: '缺少code或redirect_uri'
});
}
// 验证授权码
const authCode = await OAuthToken.validateAuthCode(code, client_id, redirect_uri);
if (!authCode) {
return res.status(400).json({
success: false,
message: '无效的授权码'
});
}
// 验证重定向URI
const isValidRedirectUri = await OAuthClient.validateRedirectUri(client_id, redirect_uri);
if (!isValidRedirectUri) {
return res.status(400).json({
success: false,
message: '无效的重定向URI'
});
}
// 生成访问令牌和刷新令牌
const accessToken = OAuthToken.generateAccessToken();
const refreshToken = OAuthToken.generateRefreshToken();
// 创建访问令牌
const accessTokenData = await OAuthToken.createAccessToken({
token: accessToken,
clientId: client_id,
userId: authCode.user_id,
scopes: authCode.scopes
});
// 创建刷新令牌
await OAuthToken.createRefreshToken({
token: refreshToken,
accessTokenId: accessTokenData.id,
clientId: client_id,
userId: authCode.user_id
});
// 删除已使用的授权码
await OAuthToken.deleteAuthCode(code);
res.json({
success: true,
data: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600, // 1小时
refresh_token: refreshToken,
scope: authCode.scopes.join(' ')
}
});
} else if (grant_type === 'refresh_token') {
// 刷新令牌流程
if (!refresh_token) {
return res.status(400).json({
success: false,
message: '缺少refresh_token'
});
}
// 验证刷新令牌
const refreshTokenData = await OAuthToken.validateRefreshToken(refresh_token, client_id);
if (!refreshTokenData) {
return res.status(400).json({
success: false,
message: '无效的刷新令牌'
});
}
// 生成新的访问令牌
const newAccessToken = OAuthToken.generateAccessToken();
const newRefreshToken = OAuthToken.generateRefreshToken();
// 创建新的访问令牌
const newAccessTokenData = await OAuthToken.createAccessToken({
token: newAccessToken,
clientId: client_id,
userId: refreshTokenData.user_id,
scopes: refreshTokenData.scopes
});
// 创建新的刷新令牌
await OAuthToken.createRefreshToken({
token: newRefreshToken,
accessTokenId: newAccessTokenData.id,
clientId: client_id,
userId: refreshTokenData.user_id
});
// 撤销旧的刷新令牌
await OAuthToken.revokeRefreshToken(refresh_token);
res.json({
success: true,
data: {
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
scope: refreshTokenData.scopes.join(' ')
}
});
} else {
return res.status(400).json({
success: false,
message: '不支持的授权类型'
});
}
} catch (error) {
console.error('令牌交换失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 3. 撤销令牌端点
router.post('/revoke', [
body('token').notEmpty().withMessage('token不能为空'),
body('client_id').notEmpty().withMessage('client_id不能为空'),
body('client_secret').notEmpty().withMessage('client_secret不能为空'),
handleValidationErrors
], async (req, res) => {
try {
const { token, client_id, client_secret } = req.body;
// 验证客户端
const isValidClient = await OAuthClient.validateClient(client_id, client_secret);
if (!isValidClient) {
return res.status(401).json({
success: false,
message: '无效的客户端凭据'
});
}
// 撤销令牌
const revoked = await OAuthToken.revokeAccessToken(token);
if (!revoked) {
await OAuthToken.revokeRefreshToken(token);
}
res.json({
success: true,
message: '令牌已撤销'
});
} catch (error) {
console.error('撤销令牌失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 4. 用户信息端点
router.get('/userinfo', authenticateOAuthToken, requireScope('read'), async (req, res) => {
try {
res.json({
success: true,
data: {
user_id: req.oauth.userId,
username: req.oauth.username,
email: req.oauth.email,
scopes: req.oauth.scopes
}
});
} catch (error) {
console.error('获取用户信息失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
// 5. 令牌信息端点
router.get('/tokeninfo', async (req, res) => {
try {
const { access_token } = req.query;
if (!access_token) {
return res.status(400).json({
success: false,
message: '缺少access_token参数'
});
}
const tokenData = await OAuthToken.validateAccessToken(access_token);
if (!tokenData) {
return res.status(400).json({
success: false,
message: '无效的访问令牌'
});
}
res.json({
success: true,
data: {
user_id: tokenData.user_id,
client_id: tokenData.client_id,
scopes: tokenData.scopes,
expires_in: Math.floor((new Date(tokenData.expires_at) - new Date()) / 1000)
}
});
} catch (error) {
console.error('获取令牌信息失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
module.exports = router;

34
src/App.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Box } from '@mui/material'
import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import OAuthAuthorize from './pages/OAuthAuthorize'
import { AuthProvider } from './contexts/AuthContext'
import PrivateRoute from './components/PrivateRoute'
function App() {
return (
<AuthProvider>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/oauth/authorize" element={<OAuthAuthorize />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/login" replace />} />
</Routes>
</Box>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { CircularProgress, Box } from '@mui/material'
const PrivateRoute = ({ children }) => {
const { user, loading } = useAuth()
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
>
<CircularProgress />
</Box>
)
}
return user ? children : <Navigate to="/login" replace />
}
export default PrivateRoute

View File

@ -0,0 +1,91 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import axios from 'axios'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
checkAuthStatus()
} else {
setLoading(false)
}
}, [])
const checkAuthStatus = async () => {
try {
const response = await axios.get('/api/auth/profile')
setUser(response.data.data)
} catch (error) {
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
} finally {
setLoading(false)
}
}
const login = async (credentials) => {
try {
const response = await axios.post('/api/auth/login', credentials)
const { token, user } = response.data.data
localStorage.setItem('token', token)
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || '登录失败'
}
}
}
const register = async (userData) => {
try {
const response = await axios.post('/api/auth/register', userData)
const { token, user } = response.data.data
localStorage.setItem('token', token)
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || '注册失败'
}
}
}
const logout = () => {
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
setUser(null)
}
const value = {
user,
loading,
login,
register,
logout
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

28
src/main.jsx Normal file
View File

@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>
)

273
src/pages/Dashboard.jsx Normal file
View File

@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
Typography,
Box,
Button,
Grid,
Card,
CardContent,
CardActions,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert
} from '@mui/material'
import {
Person,
Email,
CalendarToday,
Security,
Add,
Delete,
Visibility,
VisibilityOff,
Logout
} from '@mui/icons-material'
import axios from 'axios'
const Dashboard = () => {
const { user, logout } = useAuth()
const [clients, setClients] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [openCreateDialog, setOpenCreateDialog] = useState(false)
const [newClient, setNewClient] = useState({
name: '',
description: '',
redirect_uris: [''],
scopes: ['read', 'write']
})
useEffect(() => {
fetchClients()
}, [])
const fetchClients = async () => {
try {
const response = await axios.get('/api/oauth/clients')
setClients(response.data.data.clients)
} catch (error) {
setError('获取客户端列表失败')
}
}
const handleCreateClient = async () => {
setLoading(true)
try {
await axios.post('/api/oauth/clients', newClient)
setOpenCreateDialog(false)
setNewClient({ name: '', description: '', redirect_uris: [''], scopes: ['read', 'write'] })
fetchClients()
} catch (error) {
setError(error.response?.data?.message || '创建客户端失败')
}
setLoading(false)
}
const handleDeleteClient = async (clientId) => {
if (window.confirm('确定要删除这个客户端吗?')) {
try {
await axios.delete(`/api/oauth/clients/${clientId}`)
fetchClients()
} catch (error) {
setError('删除客户端失败')
}
}
}
const handleLogout = () => {
logout()
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
个人中心
</Typography>
<Typography variant="body1" color="text.secondary">
欢迎回来{user?.username}
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
<Grid container spacing={3}>
{/* 用户信息卡片 */}
<Grid item xs={12} md={4}>
<Paper elevation={2} sx={{ p: 3 }}>
<Box display="flex" alignItems="center" mb={2}>
<Person sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">用户信息</Typography>
</Box>
<List>
<ListItem>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary="用户名" secondary={user?.username} />
</ListItem>
<ListItem>
<ListItemIcon>
<Email />
</ListItemIcon>
<ListItemText primary="邮箱" secondary={user?.email} />
</ListItem>
<ListItem>
<ListItemIcon>
<CalendarToday />
</ListItemIcon>
<ListItemText
primary="注册时间"
secondary={new Date(user?.created_at).toLocaleDateString()}
/>
</ListItem>
</List>
<Button
variant="outlined"
color="error"
startIcon={<Logout />}
onClick={handleLogout}
fullWidth
>
退出登录
</Button>
</Paper>
</Grid>
{/* OAuth客户端管理 */}
<Grid item xs={12} md={8}>
<Paper elevation={2} sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Box display="flex" alignItems="center">
<Security sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">OAuth客户端</Typography>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setOpenCreateDialog(true)}
>
创建客户端
</Button>
</Box>
{clients.length === 0 ? (
<Box textAlign="center" py={4}>
<Typography variant="body2" color="text.secondary">
还没有OAuth客户端点击上方按钮创建一个
</Typography>
</Box>
) : (
<Grid container spacing={2}>
{clients.map((client) => (
<Grid item xs={12} key={client.client_id}>
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" gutterBottom>
{client.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{client.description}
</Typography>
<Box mt={1}>
<Typography variant="caption" color="text.secondary">
客户端ID: {client.client_id}
</Typography>
</Box>
<Box mt={1}>
{client.scopes.map((scope) => (
<Chip
key={scope}
label={scope}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
/>
))}
</Box>
</Box>
<Button
color="error"
size="small"
onClick={() => handleDeleteClient(client.client_id)}
>
<Delete />
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Paper>
</Grid>
</Grid>
{/* 创建客户端对话框 */}
<Dialog open={openCreateDialog} onClose={() => setOpenCreateDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>创建OAuth客户端</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="客户端名称"
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
margin="normal"
required
/>
<TextField
fullWidth
label="描述"
value={newClient.description}
onChange={(e) => setNewClient({ ...newClient, description: e.target.value })}
margin="normal"
multiline
rows={2}
/>
<TextField
fullWidth
label="重定向URI"
value={newClient.redirect_uris[0]}
onChange={(e) => setNewClient({
...newClient,
redirect_uris: [e.target.value]
})}
margin="normal"
required
placeholder="http://localhost:3001/callback"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenCreateDialog(false)}>取消</Button>
<Button
onClick={handleCreateClient}
variant="contained"
disabled={loading || !newClient.name}
>
{loading ? '创建中...' : '创建'}
</Button>
</DialogActions>
</Dialog>
</Container>
)
}
export default Dashboard

153
src/pages/Login.jsx Normal file
View File

@ -0,0 +1,153 @@
import React, { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
InputAdornment,
IconButton
} from '@mui/material'
import { Visibility, VisibilityOff, Login as LoginIcon } from '@mui/icons-material'
const Login = () => {
const { login, user } = useAuth()
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (user) {
return <Navigate to="/dashboard" replace />
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(formData)
if (!result.success) {
setError(result.message)
}
setLoading(false)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400
}}
>
<Box textAlign="center" mb={3}>
<LoginIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
登录
</Typography>
<Typography variant="body2" color="text.secondary">
欢迎回来请登录您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoComplete="username"
/>
<TextField
fullWidth
label="密码"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
margin="normal"
required
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? '登录中...' : '登录'}
</Button>
<Box textAlign="center">
<Typography variant="body2">
还没有账户{' '}
<Link to="/register" style={{ textDecoration: 'none' }}>
<Typography
component="span"
variant="body2"
color="primary"
sx={{ cursor: 'pointer' }}
>
立即注册
</Typography>
</Link>
</Typography>
</Box>
</form>
</Paper>
</Box>
</Container>
)
}
export default Login

View File

@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
Typography,
Box,
Button,
Grid,
Card,
CardContent,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Alert,
CircularProgress
} from '@mui/material'
import {
Security,
CheckCircle,
Cancel,
Person,
Email,
CalendarToday,
Visibility,
VisibilityOff
} from '@mui/icons-material'
import axios from 'axios'
const OAuthAuthorize = () => {
const { user } = useAuth()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [clientInfo, setClientInfo] = useState(null)
const clientId = searchParams.get('client_id')
const redirectUri = searchParams.get('redirect_uri')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const responseType = searchParams.get('response_type')
useEffect(() => {
if (!user) {
navigate('/login')
return
}
if (!clientId || !redirectUri || responseType !== 'code') {
setError('无效的授权请求')
return
}
// 这里可以添加客户端信息获取逻辑
// 为了演示,我们使用默认信息
setClientInfo({
name: '第三方应用',
description: '请求访问您的账户信息',
scopes: scope ? scope.split(' ') : ['read', 'write']
})
}, [user, clientId, redirectUri, scope, responseType, navigate])
const handleAuthorize = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scope || 'read write',
state: state || ''
})
const response = await axios.get(`/api/oauth/authorize?${params}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
})
if (response.data.success) {
const { redirect_url } = response.data.data
window.location.href = redirect_url
}
} catch (error) {
setError(error.response?.data?.message || '授权失败')
}
setLoading(false)
}
const handleDeny = () => {
// 拒绝授权,重定向回应用
const denyUrl = new URL(redirectUri)
denyUrl.searchParams.set('error', 'access_denied')
if (state) {
denyUrl.searchParams.set('state', state)
}
window.location.href = denyUrl.toString()
}
if (!user) {
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularProgress />
</Box>
</Container>
)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 500
}}
>
<Box textAlign="center" mb={3}>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
授权请求
</Typography>
<Typography variant="body2" color="text.secondary">
第三方应用请求访问您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{clientInfo && (
<>
{/* 应用信息 */}
<Card variant="outlined" sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{clientInfo.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{clientInfo.description}
</Typography>
<Box mt={2}>
<Typography variant="body2" color="text.secondary" gutterBottom>
请求的权限
</Typography>
{clientInfo.scopes.map((scope) => (
<Chip
key={scope}
label={scope === 'read' ? '读取信息' : scope === 'write' ? '写入信息' : scope}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
color="primary"
variant="outlined"
/>
))}
</Box>
</CardContent>
</Card>
{/* 用户信息 */}
<Card variant="outlined" sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
您的账户信息
</Typography>
<List dense>
<ListItem>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary="用户名" secondary={user.username} />
</ListItem>
<ListItem>
<ListItemIcon>
<Email />
</ListItemIcon>
<ListItemText primary="邮箱" secondary={user.email} />
</ListItem>
<ListItem>
<ListItemIcon>
<CalendarToday />
</ListItemIcon>
<ListItemText
primary="注册时间"
secondary={new Date(user.created_at).toLocaleDateString()}
/>
</ListItem>
</List>
</CardContent>
</Card>
{/* 操作按钮 */}
<Grid container spacing={2}>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
color="error"
startIcon={<Cancel />}
onClick={handleDeny}
disabled={loading}
>
拒绝
</Button>
</Grid>
<Grid item xs={6}>
<Button
fullWidth
variant="contained"
startIcon={<CheckCircle />}
onClick={handleAuthorize}
disabled={loading}
>
{loading ? '授权中...' : '授权'}
</Button>
</Grid>
</Grid>
</>
)}
</Paper>
</Box>
</Container>
)
}
export default OAuthAuthorize

210
src/pages/Register.jsx Normal file
View File

@ -0,0 +1,210 @@
import React, { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
InputAdornment,
IconButton
} from '@mui/material'
import { Visibility, VisibilityOff, PersonAdd as RegisterIcon } from '@mui/icons-material'
const Register = () => {
const { register, user } = useAuth()
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (user) {
return <Navigate to="/dashboard" replace />
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const validateForm = () => {
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
return false
}
if (formData.password.length < 6) {
setError('密码长度至少6位')
return false
}
return true
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!validateForm()) {
return
}
setLoading(true)
const { confirmPassword, ...userData } = formData
const result = await register(userData)
if (!result.success) {
setError(result.message)
}
setLoading(false)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400
}}
>
<Box textAlign="center" mb={3}>
<RegisterIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
注册
</Typography>
<Typography variant="body2" color="text.secondary">
创建您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoComplete="username"
/>
<TextField
fullWidth
label="邮箱"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
margin="normal"
required
autoComplete="email"
/>
<TextField
fullWidth
label="密码"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
margin="normal"
required
autoComplete="new-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<TextField
fullWidth
label="确认密码"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleChange}
margin="normal"
required
autoComplete="new-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? '注册中...' : '注册'}
</Button>
<Box textAlign="center">
<Typography variant="body2">
已有账户{' '}
<Link to="/login" style={{ textDecoration: 'none' }}>
<Typography
component="span"
variant="body2"
color="primary"
sx={{ cursor: 'pointer' }}
>
立即登录
</Typography>
</Link>
</Typography>
</Box>
</form>
</Paper>
</Box>
</Container>
)
}
export default Register

100
test-api.js Normal file
View File

@ -0,0 +1,100 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000/api/auth';
// 测试数据
const testUser = {
username: 'testuser',
email: 'test@example.com',
password: 'Password123'
};
let authToken = '';
// 测试函数
async function testAPI() {
console.log('🚀 开始测试API...\n');
try {
// 1. 测试注册
console.log('1. 测试用户注册...');
const registerResponse = await axios.post(`${BASE_URL}/register`, testUser);
console.log('✅ 注册成功:', registerResponse.data.message);
authToken = registerResponse.data.data.token;
console.log('Token:', authToken.substring(0, 20) + '...\n');
// 2. 测试重复注册(应该失败)
console.log('2. 测试重复注册...');
try {
await axios.post(`${BASE_URL}/register`, testUser);
} catch (error) {
console.log('✅ 重复注册被正确拒绝:', error.response.data.message);
}
console.log('');
// 3. 测试登录
console.log('3. 测试用户登录...');
const loginResponse = await axios.post(`${BASE_URL}/login`, {
username: testUser.username,
password: testUser.password
});
console.log('✅ 登录成功:', loginResponse.data.message);
console.log('');
// 4. 测试邮箱登录
console.log('4. 测试邮箱登录...');
const emailLoginResponse = await axios.post(`${BASE_URL}/login`, {
username: testUser.email,
password: testUser.password
});
console.log('✅ 邮箱登录成功:', emailLoginResponse.data.message);
console.log('');
// 5. 测试获取用户信息
console.log('5. 测试获取用户信息...');
const profileResponse = await axios.get(`${BASE_URL}/profile`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
console.log('✅ 获取用户信息成功:', profileResponse.data.data.user.username);
console.log('');
// 6. 测试认证中间件
console.log('6. 测试认证中间件...');
const testResponse = await axios.get(`${BASE_URL}/test`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
console.log('✅ 认证中间件测试成功:', testResponse.data.message);
console.log('');
// 7. 测试无效token
console.log('7. 测试无效token...');
try {
await axios.get(`${BASE_URL}/profile`, {
headers: {
'Authorization': 'Bearer invalid_token'
}
});
} catch (error) {
console.log('✅ 无效token被正确拒绝:', error.response.data.message);
}
console.log('');
// 8. 测试服务器状态
console.log('8. 测试服务器状态...');
const healthResponse = await axios.get('http://localhost:3000/health');
console.log('✅ 服务器状态正常:', healthResponse.data.message);
console.log('');
console.log('🎉 所有测试通过!');
} catch (error) {
console.error('❌ 测试失败:', error.response?.data || error.message);
}
}
// 运行测试
testAPI();

219
test-oauth.js Normal file
View File

@ -0,0 +1,219 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000';
const AUTH_URL = `${BASE_URL}/api/auth`;
const OAUTH_URL = `${BASE_URL}/api/oauth`;
// 测试数据
const testUser = {
username: `oauthuser${Date.now()}`,
email: `oauthuser${Date.now()}@example.com`,
password: 'TestPassword123'
};
const testOAuthClient = {
name: '测试OAuth客户端',
description: '用于测试的OAuth客户端',
redirect_uris: ['http://localhost:3001/callback'],
scopes: ['read', 'write']
};
let authToken = '';
let clientId = '';
let clientSecret = '';
let authCode = '';
let accessToken = '';
let refreshToken = '';
async function testOAuthFlow() {
console.log('🚀 开始测试OAuth功能...\n');
try {
// 1. 注册测试用户
console.log('1. 注册测试用户...');
const registerResponse = await axios.post(`${AUTH_URL}/register`, testUser);
if (registerResponse.data.success) {
console.log('✅ 用户注册成功:', registerResponse.data.message);
} else {
console.log('❌ 用户注册失败:', registerResponse.data.message);
return;
}
// 2. 登录获取JWT令牌
console.log('\n2. 登录获取JWT令牌...');
const loginResponse = await axios.post(`${AUTH_URL}/login`, {
username: testUser.username,
password: testUser.password
});
if (loginResponse.data.success) {
authToken = loginResponse.data.data.token;
console.log('✅ 登录成功获取到JWT令牌');
} else {
console.log('❌ 登录失败:', loginResponse.data.message);
return;
}
// 3. 创建OAuth客户端
console.log('\n3. 创建OAuth客户端...');
const clientResponse = await axios.post(`${OAUTH_URL}/clients`, testOAuthClient, {
headers: { Authorization: `Bearer ${authToken}` }
});
if (clientResponse.data.success) {
const clientData = clientResponse.data.data;
clientId = clientData.client_id;
clientSecret = clientData.client_secret;
console.log('✅ OAuth客户端创建成功:', clientResponse.data.message);
console.log(`Client ID: ${clientId}`);
console.log(`Client Secret: ${clientSecret.substring(0, 16)}...`);
} else {
console.log('❌ OAuth客户端创建失败:', clientResponse.data.message);
return;
}
// 4. 测试授权端点
console.log('\n4. 测试授权端点...');
const authorizeUrl = `${OAUTH_URL}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent('http://localhost:3001/callback')}&scope=read write&state=test_state_123`;
const authorizeResponse = await axios.get(authorizeUrl, {
headers: { Authorization: `Bearer ${authToken}` }
});
if (authorizeResponse.data.success) {
authCode = authorizeResponse.data.data.code;
console.log('✅ 授权成功:', authorizeResponse.data.message);
console.log(`授权码: ${authCode.substring(0, 20)}...`);
} else {
console.log('❌ 授权失败:', authorizeResponse.data.message);
return;
}
// 5. 测试令牌交换
console.log('\n5. 测试令牌交换...');
const tokenResponse = await axios.post(`${OAUTH_URL}/token`, {
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: authCode,
redirect_uri: 'http://localhost:3001/callback'
});
if (tokenResponse.data.success) {
const tokenData = tokenResponse.data.data;
accessToken = tokenData.access_token;
refreshToken = tokenData.refresh_token;
console.log('✅ 令牌交换成功');
console.log(`访问令牌: ${accessToken.substring(0, 20)}...`);
console.log(`刷新令牌: ${refreshToken.substring(0, 20)}...`);
} else {
console.log('❌ 令牌交换失败:', tokenResponse.data.message);
return;
}
// 6. 测试用户信息端点
console.log('\n6. 测试用户信息端点...');
const userInfoResponse = await axios.get(`${OAUTH_URL}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (userInfoResponse.data.success) {
console.log('✅ 用户信息获取成功');
console.log('用户信息:', userInfoResponse.data.data);
} else {
console.log('❌ 用户信息获取失败:', userInfoResponse.data.message);
}
// 7. 测试令牌信息端点
console.log('\n7. 测试令牌信息端点...');
const tokenInfoResponse = await axios.get(`${OAUTH_URL}/tokeninfo`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (tokenInfoResponse.data.success) {
console.log('✅ 令牌信息获取成功');
console.log('令牌信息:', tokenInfoResponse.data.data);
} else {
console.log('❌ 令牌信息获取失败:', tokenInfoResponse.data.message);
}
// 8. 测试刷新令牌
console.log('\n8. 测试刷新令牌...');
const refreshResponse = await axios.post(`${OAUTH_URL}/token`, {
grant_type: 'refresh_token',
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken
});
if (refreshResponse.data.success) {
const newTokenData = refreshResponse.data.data;
accessToken = newTokenData.access_token;
refreshToken = newTokenData.refresh_token;
console.log('✅ 刷新令牌成功');
console.log(`新访问令牌: ${accessToken.substring(0, 20)}...`);
} else {
console.log('❌ 刷新令牌失败:', refreshResponse.data.message);
}
// 9. 测试撤销令牌
console.log('\n9. 测试撤销令牌...');
const revokeResponse = await axios.post(`${OAUTH_URL}/revoke`, {
token: accessToken,
client_id: clientId,
client_secret: clientSecret
});
if (revokeResponse.data.success) {
console.log('✅ 令牌撤销成功');
} else {
console.log('❌ 令牌撤销失败:', revokeResponse.data.message);
}
// 10. 测试OAuth客户端管理
console.log('\n10. 测试OAuth客户端管理...');
const clientsResponse = await axios.get(`${OAUTH_URL}/clients`, {
headers: { Authorization: `Bearer ${authToken}` }
});
if (clientsResponse.data.success) {
console.log('✅ 获取客户端列表成功');
console.log(`客户端数量: ${clientsResponse.data.data.clients.length}`);
} else {
console.log('❌ 获取客户端列表失败:', clientsResponse.data.message);
}
// 11. 测试获取客户端密钥
console.log('\n11. 测试获取客户端密钥...');
const secretResponse = await axios.get(`${OAUTH_URL}/clients/${clientId}/secret`, {
headers: { Authorization: `Bearer ${authToken}` }
});
if (secretResponse.data.success) {
console.log('✅ 获取客户端密钥成功');
console.log(`密钥: ${secretResponse.data.data.client_secret.substring(0, 16)}...`);
} else {
console.log('❌ 获取客户端密钥失败:', secretResponse.data.message);
}
// 12. 测试OAuth发现端点
console.log('\n12. 测试OAuth发现端点...');
const discoveryResponse = await axios.get(`${BASE_URL}/.well-known/oauth-authorization-server`);
if (discoveryResponse.status === 200) {
console.log('✅ OAuth发现端点正常');
console.log('授权端点:', discoveryResponse.data.authorization_endpoint);
console.log('令牌端点:', discoveryResponse.data.token_endpoint);
} else {
console.log('❌ OAuth发现端点异常');
}
console.log('\n🎉 OAuth完整流程测试通过');
} catch (error) {
console.error('❌ OAuth测试失败:', error.response?.data || error.message);
}
}
// 运行测试
testOAuthFlow();

15
vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})