v0.1.0
This commit is contained in:
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal 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
406
README.md
Normal 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** - 支持PKCE(Proof 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
24
config/database.js
Normal 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;
|
274
examples/oauth-client-example.html
Normal file
274
examples/oauth-client-example.html
Normal 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
17
index.html
Normal 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
134
index.js
Normal 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
39
middleware/auth.js
Normal 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
118
middleware/oauth.js
Normal 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
56
middleware/validation.js
Normal 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
206
models/OAuthClient.js
Normal 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
207
models/OAuthToken.js
Normal 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
92
models/User.js
Normal 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
40
package.json
Normal 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
2486
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
245
public/index.html
Normal file
245
public/index.html
Normal 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
162
routes/auth.js
Normal 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
349
routes/oauth-clients.js
Normal 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
375
routes/oauth.js
Normal 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
34
src/App.jsx
Normal 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
|
25
src/components/PrivateRoute.jsx
Normal file
25
src/components/PrivateRoute.jsx
Normal 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
|
91
src/contexts/AuthContext.jsx
Normal file
91
src/contexts/AuthContext.jsx
Normal 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
28
src/main.jsx
Normal 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
273
src/pages/Dashboard.jsx
Normal 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
153
src/pages/Login.jsx
Normal 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
|
250
src/pages/OAuthAuthorize.jsx
Normal file
250
src/pages/OAuthAuthorize.jsx
Normal 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
210
src/pages/Register.jsx
Normal 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
100
test-api.js
Normal 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
219
test-oauth.js
Normal 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
15
vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user