支持速率限制

然后把oauth单独放一个页面
一些UI调整
This commit is contained in:
2025-07-30 12:19:38 -07:00
parent b965f90c97
commit 9262ef4076
11 changed files with 745 additions and 404 deletions

View File

@ -23,6 +23,12 @@
- 自动清理过期令牌 - 自动清理过期令牌
- 重定向URI验证 - 重定向URI验证
- 客户端所有权验证 - 客户端所有权验证
- **速率限制** - 针对不同重要程度的接口设置不同限制
- 通用接口15分钟内最多100次请求
- 登录/注册15分钟内最多5次尝试
- OAuth授权15分钟内最多3次尝试
- OAuth令牌15分钟内最多10次请求
- OAuth客户端管理15分钟内最多20次操作
### 📊 OAuth客户端管理 ### 📊 OAuth客户端管理
- 创建OAuth客户端 - 创建OAuth客户端

View File

@ -8,6 +8,7 @@ const OAuthToken = require('./models/OAuthToken');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const oauthRoutes = require('./routes/oauth'); const oauthRoutes = require('./routes/oauth');
const oauthClientRoutes = require('./routes/oauth-clients'); const oauthClientRoutes = require('./routes/oauth-clients');
const { generalLimiter } = require('./middleware/rateLimit');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@ -17,6 +18,9 @@ app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// 应用通用速率限制
app.use(generalLimiter);
// 请求日志中间件 // 请求日志中间件
app.use((req, res, next) => { app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);

69
middleware/rateLimit.js Normal file
View File

@ -0,0 +1,69 @@
const rateLimit = require('express-rate-limit');
// 通用速率限制
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 限制每个IP 15分钟内最多100次请求
message: {
success: false,
message: '请求过于频繁,请稍后再试'
},
standardHeaders: true,
legacyHeaders: false,
});
// 登录注册接口限制(更严格)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 5, // 限制每个IP 15分钟内最多5次登录/注册尝试
message: {
success: false,
message: '登录/注册尝试过于频繁请15分钟后再试'
},
standardHeaders: true,
legacyHeaders: false,
});
// OAuth授权接口限制最严格
const oauthAuthLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 10, // 限制每个IP 15分钟内最多3次OAuth授权尝试
message: {
success: false,
message: 'OAuth授权请求过于频繁请15分钟后再试'
},
standardHeaders: true,
legacyHeaders: false,
});
// OAuth令牌接口限制严格
const oauthTokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 10, // 限制每个IP 15分钟内最多10次令牌请求
message: {
success: false,
message: '令牌请求过于频繁,请稍后再试'
},
standardHeaders: true,
legacyHeaders: false,
});
// OAuth客户端管理接口限制
const oauthClientLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 20, // 限制每个IP 15分钟内最多20次客户端管理操作
message: {
success: false,
message: '客户端管理操作过于频繁,请稍后再试'
},
standardHeaders: true,
legacyHeaders: false,
});
module.exports = {
generalLimiter,
authLimiter,
oauthAuthLimiter,
oauthTokenLimiter,
oauthClientLimiter
};

View File

@ -10,33 +10,42 @@
"test:oauth": "node test-oauth.js", "test:oauth": "node test-oauth.js",
"test:oauth-flow": "node test-oauth-flow.js", "test:oauth-flow": "node test-oauth-flow.js",
"test:redirect-uri": "node test-redirect-uri.js", "test:redirect-uri": "node test-redirect-uri.js",
"test:rate-limit": "node test-rate-limit.js",
"dev:frontend": "vite", "dev:frontend": "vite",
"build:frontend": "vite build", "build:frontend": "vite build",
"preview:frontend": "vite preview" "preview:frontend": "vite preview"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express-validator": "^7.0.1" "express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
}, },
"devDependencies": { "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/react": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"react-router-dom": "^6.15.0" "@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"@vitejs/plugin-react": "^4.0.0",
"axios": "^1.5.0",
"nodemon": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"vite": "^4.4.0"
}, },
"keywords": ["auth", "api", "postgresql", "login", "register", "oauth"], "keywords": [
"auth",
"api",
"postgresql",
"login",
"register",
"oauth"
],
"author": "", "author": "",
"license": "MIT" "license": "MIT"
} }

20
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
express: express:
specifier: ^4.18.2 specifier: ^4.18.2
version: 4.21.2 version: 4.21.2
express-rate-limit:
specifier: ^8.0.1
version: 8.0.1(express@4.21.2)
express-validator: express-validator:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.2.1 version: 7.2.1
@ -698,6 +701,12 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
express-rate-limit@8.0.1:
resolution: {integrity: sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express-validator@7.2.1: express-validator@7.2.1:
resolution: {integrity: sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==} resolution: {integrity: sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==}
engines: {node: '>= 8.0.0'} engines: {node: '>= 8.0.0'}
@ -803,6 +812,10 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -1926,6 +1939,11 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
express-rate-limit@8.0.1(express@4.21.2):
dependencies:
express: 4.21.2
ip-address: 10.0.1
express-validator@7.2.1: express-validator@7.2.1:
dependencies: dependencies:
lodash: 4.17.21 lodash: 4.17.21
@ -2067,6 +2085,8 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
ip-address@10.0.1: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {} is-arrayish@0.2.1: {}

View File

@ -6,11 +6,12 @@ const {
loginValidation, loginValidation,
handleValidationErrors handleValidationErrors
} = require('../middleware/validation'); } = require('../middleware/validation');
const { authLimiter } = require('../middleware/rateLimit');
const router = express.Router(); const router = express.Router();
// 注册路由 // 注册路由
router.post('/register', registerValidation, handleValidationErrors, async (req, res) => { router.post('/register', authLimiter, registerValidation, handleValidationErrors, async (req, res) => {
try { try {
const { username, email, password } = req.body; const { username, email, password } = req.body;
@ -62,7 +63,7 @@ router.post('/register', registerValidation, handleValidationErrors, async (req,
}); });
// 登录路由 // 登录路由
router.post('/login', loginValidation, handleValidationErrors, async (req, res) => { router.post('/login', authLimiter, loginValidation, handleValidationErrors, async (req, res) => {
try { try {
const { username, password } = req.body; const { username, password } = req.body;

View File

@ -2,6 +2,7 @@ const express = require('express');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const OAuthClient = require('../models/OAuthClient'); const OAuthClient = require('../models/OAuthClient');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { oauthClientLimiter } = require('../middleware/rateLimit');
const router = express.Router(); const router = express.Router();
@ -93,7 +94,7 @@ const validateRedirectUris = (req, res, next) => {
}; };
// 1. 创建OAuth客户端 // 1. 创建OAuth客户端
router.post('/clients', authenticateToken, createClientValidation, validateRedirectUris, handleValidationErrors, async (req, res) => { router.post('/clients', oauthClientLimiter, authenticateToken, createClientValidation, validateRedirectUris, handleValidationErrors, async (req, res) => {
try { try {
const { name, description, redirect_uris, scopes } = req.body; const { name, description, redirect_uris, scopes } = req.body;
const userId = req.user.userId; const userId = req.user.userId;

View File

@ -5,6 +5,7 @@ const OAuthToken = require('../models/OAuthToken');
const User = require('../models/User'); const User = require('../models/User');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { authenticateOAuthToken, requireScope } = require('../middleware/oauth'); const { authenticateOAuthToken, requireScope } = require('../middleware/oauth');
const { oauthAuthLimiter, oauthTokenLimiter } = require('../middleware/rateLimit');
const router = express.Router(); const router = express.Router();
@ -58,7 +59,7 @@ const handleValidationErrors = (req, res, next) => {
}; };
// 1. 授权端点 - 验证参数并返回授权信息 // 1. 授权端点 - 验证参数并返回授权信息
router.get('/authorize', authenticateToken, async (req, res) => { router.get('/authorize', oauthAuthLimiter, authenticateToken, async (req, res) => {
try { try {
const { const {
response_type, response_type,
@ -141,8 +142,8 @@ router.get('/authorize', authenticateToken, async (req, res) => {
} }
}); });
// 2. 用户同意授权端点 // 2. 用户同意/拒绝授权端点
router.post('/authorize/consent', authenticateToken, async (req, res) => { router.post('/authorize/consent', oauthAuthLimiter, authenticateToken, async (req, res) => {
try { try {
const { const {
client_id, client_id,
@ -247,7 +248,7 @@ router.post('/authorize/consent', authenticateToken, async (req, res) => {
}); });
// 3. 令牌端点 - 交换授权码获取访问令牌 // 3. 令牌端点 - 交换授权码获取访问令牌
router.post('/token', [ router.post('/token', oauthTokenLimiter, [
body('grant_type').notEmpty().withMessage('grant_type不能为空'), body('grant_type').notEmpty().withMessage('grant_type不能为空'),
body('client_id').notEmpty().withMessage('client_id不能为空'), body('client_id').notEmpty().withMessage('client_id不能为空'),
body('client_secret').notEmpty().withMessage('client_secret不能为空'), body('client_secret').notEmpty().withMessage('client_secret不能为空'),

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { import {
Container, Container,
@ -9,89 +9,27 @@ import {
Grid, Grid,
Card, Card,
CardContent, CardContent,
CardActions, Divider
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert
} from '@mui/material' } from '@mui/material'
import { import {
Person, Person,
Email, Email,
CalendarToday, CalendarToday,
Security, Logout,
Add, Security
Delete,
Visibility,
VisibilityOff,
Logout
} from '@mui/icons-material' } from '@mui/icons-material'
import axios from 'axios' import { useNavigate } from 'react-router-dom'
const Dashboard = () => { const Dashboard = () => {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [clients, setClients] = useState([]) const navigate = useNavigate()
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 = () => { const handleLogout = () => {
logout() logout()
} }
return ( return (
<Container maxWidth="lg" sx={{ py: 4 }}> <Container maxWidth="md" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom> <Typography variant="h4" component="h1" gutterBottom>
个人中心 个人中心
@ -101,171 +39,74 @@ const Dashboard = () => {
</Typography> </Typography>
</Box> </Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
<Grid container spacing={3}> <Grid container spacing={3}>
{/* 用户信息卡片 */} {/* 用户信息卡片 */}
<Grid item xs={12} md={4}> <Grid item xs={12}>
<Paper elevation={2} sx={{ p: 3 }}> <Card>
<Box display="flex" alignItems="center" mb={2}> <CardContent>
<Person sx={{ mr: 1, color: 'primary.main' }} /> <Typography variant="h6" gutterBottom>
<Typography variant="h6">用户信息</Typography> 用户信息
</Box> </Typography>
<List> <Divider sx={{ mb: 2 }} />
<ListItem>
<ListItemIcon> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Person /> <Person sx={{ mr: 2, color: 'primary.main' }} />
</ListItemIcon> <Box>
<ListItemText primary="用户名" secondary={user?.username} /> <Typography variant="body2" color="text.secondary">
</ListItem> 用户名
<ListItem> </Typography>
<ListItemIcon> <Typography variant="body1">
<Email /> {user?.username}
</ListItemIcon> </Typography>
<ListItemText primary="邮箱" secondary={user?.email} /> </Box>
</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> </Box>
<Button
variant="contained" <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
startIcon={<Add />} <Email sx={{ mr: 2, color: 'primary.main' }} />
onClick={() => setOpenCreateDialog(true)} <Box>
> <Typography variant="body2" color="text.secondary">
创建客户端 邮箱
</Button> </Typography>
</Box> <Typography variant="body1">
{user?.email}
{clients.length === 0 ? ( </Typography>
<Box textAlign="center" py={4}> </Box>
<Typography variant="body2" color="text.secondary">
还没有OAuth客户端点击上方按钮创建一个
</Typography>
</Box> </Box>
) : (
<Grid container spacing={2}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
{clients.map((client) => ( <CalendarToday sx={{ mr: 2, color: 'primary.main' }} />
<Grid item xs={12} key={client.client_id}> <Box>
<Card variant="outlined"> <Typography variant="body2" color="text.secondary">
<CardContent> 注册时间
<Box display="flex" justifyContent="space-between" alignItems="flex-start"> </Typography>
<Box> <Typography variant="body1">
<Typography variant="h6" gutterBottom> {user?.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '未知'}
{client.name} </Typography>
</Typography> </Box>
<Typography variant="body2" color="text.secondary" gutterBottom> </Box>
{client.description} </CardContent>
</Typography> </Card>
<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>
</Grid> </Grid>
{/* 创建客户端对话框 */} {/* 功能按钮 */}
<Dialog open={openCreateDialog} onClose={() => setOpenCreateDialog(false)} maxWidth="sm" fullWidth> <Box sx={{ mt: 4, display: 'flex', justifyContent: 'center', gap: 2 }}>
<DialogTitle>创建OAuth客户端</DialogTitle> <Button
<DialogContent> variant="outlined"
<TextField startIcon={<Security />}
fullWidth onClick={() => navigate('/oauth/authorize')}
label="客户端名称" >
value={newClient.name} OAuth 管理
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })} </Button>
margin="normal" <Button
required variant="outlined"
/> color="error"
<TextField onClick={handleLogout}
fullWidth startIcon={<Logout />}
label="描述" >
value={newClient.description} 退出登录
onChange={(e) => setNewClient({ ...newClient, description: e.target.value })} </Button>
margin="normal" </Box>
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> </Container>
) )
} }

View File

@ -10,13 +10,22 @@ import {
Grid, Grid,
Card, Card,
CardContent, CardContent,
CardActions,
List, List,
ListItem, ListItem,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Chip, Chip,
Alert, Alert,
CircularProgress CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Divider,
Tabs,
Tab
} from '@mui/material' } from '@mui/material'
import { import {
Security, Security,
@ -25,18 +34,30 @@ import {
Person, Person,
Email, Email,
CalendarToday, CalendarToday,
Visibility, Add,
VisibilityOff Delete,
Settings,
Apps,
Logout
} from '@mui/icons-material' } from '@mui/icons-material'
import axios from 'axios' import axios from 'axios'
const OAuthAuthorize = () => { const OAuthAuthorize = () => {
const { user } = useAuth() const { user, logout } = useAuth()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [clientInfo, setClientInfo] = useState(null) const [clientInfo, setClientInfo] = useState(null)
const [clients, setClients] = useState([])
const [openCreateDialog, setOpenCreateDialog] = useState(false)
const [newClient, setNewClient] = useState({
name: '',
description: '',
redirect_uris: [''],
scopes: ['read', 'write']
})
const [activeTab, setActiveTab] = useState(0)
const clientId = searchParams.get('client_id') const clientId = searchParams.get('client_id')
const redirectUri = searchParams.get('redirect_uri') const redirectUri = searchParams.get('redirect_uri')
@ -45,53 +66,85 @@ const OAuthAuthorize = () => {
const responseType = searchParams.get('response_type') const responseType = searchParams.get('response_type')
useEffect(() => { useEffect(() => {
// 验证OAuth参数 // 如果有OAuth参数,显示授权页面
if (!clientId || !redirectUri || responseType !== 'code') { if (clientId && redirectUri && responseType === 'code') {
setError('无效的授权请求') setActiveTab(1)
fetchAuthInfo()
return } else {
// 否则显示客户端管理页面
setActiveTab(0)
fetchClients()
} }
}, [clientId, redirectUri, responseType])
// 获取授权信息 const fetchClients = async () => {
const fetchAuthInfo = async () => { try {
try { const response = await axios.get('/api/oauth/clients')
setLoading(true) setClients(response.data.data.clients)
const params = new URLSearchParams({ } catch (error) {
response_type: 'code', setError('获取客户端列表失败')
client_id: clientId, }
redirect_uri: redirectUri, }
scope: scope || 'read write',
state: state || ''
})
const response = await axios.get(`/api/oauth/authorize?${params}`, { const fetchAuthInfo = async () => {
headers: { try {
Authorization: `Bearer ${localStorage.getItem('token')}` setLoading(true)
} const params = new URLSearchParams({
}) response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scope || 'read write',
state: state || ''
})
if (response.data.success) { const response = await axios.get(`/api/oauth/authorize?${params}`, {
console.log(response.data.data.redirect_uri) headers: {
setClientInfo({ Authorization: `Bearer ${localStorage.getItem('token')}`
name: response.data.data.client.name,
description: response.data.data.client.description,
scopes: response.data.data.scopes,
clientId: response.data.data.client.id,
redirectUri: response.data.data.redirect_uri,
state: response.data.data.state
})
} else {
setError(response.data.message || '获取授权信息失败')
} }
})
if (response.data.success) {
setClientInfo({
name: response.data.data.client.name,
description: response.data.data.client.description,
scopes: response.data.data.scopes,
clientId: response.data.data.client.id,
redirectUri: response.data.data.redirect_uri,
state: response.data.data.state
})
} else {
setError(response.data.message || '获取授权信息失败')
}
} catch (error) {
setError(error.response?.data?.message || '获取授权信息失败')
} finally {
setLoading(false)
}
}
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) { } catch (error) {
setError(error.response?.data?.message || '获取授权信息失败') setError('删除客户端失败')
} finally {
setLoading(false)
} }
} }
}
fetchAuthInfo()
}, [clientId, redirectUri, scope, responseType, state])
const handleAuthorize = async () => { const handleAuthorize = async () => {
setLoading(true) setLoading(true)
@ -143,8 +196,13 @@ const OAuthAuthorize = () => {
} }
} }
const handleLogout = () => {
logout()
navigate('/login')
}
// 显示加载状态 // 显示加载状态
if (loading) { if (loading && activeTab === 1) {
return ( return (
<Container maxWidth="sm"> <Container maxWidth="sm">
<Box <Box
@ -162,129 +220,327 @@ const OAuthAuthorize = () => {
} }
return ( return (
<Container maxWidth="sm"> <Container maxWidth="lg" sx={{ py: 4 }}>
<Box <Box sx={{ mb: 4 }}>
sx={{ <Typography variant="h4" component="h1" gutterBottom>
minHeight: '100vh', OAuth 管理
display: 'flex', </Typography>
alignItems: 'center', <Typography variant="body1" color="text.secondary">
justifyContent: 'center' 管理OAuth客户端和第三方应用授权
}} </Typography>
> </Box>
<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 && ( {error && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error} {error}
</Alert> </Alert>
)} )}
{clientInfo && ( {/* 标签页 */}
<> <Paper sx={{ mb: 3 }}>
{/* 应用信息 */} <Tabs value={activeTab} onChange={(e, newValue) => setActiveTab(newValue)}>
<Card variant="outlined" sx={{ mb: 3 }}> <Tab
<CardContent> icon={<Apps />}
<Typography variant="h6" gutterBottom> label="客户端管理"
{clientInfo.name} iconPosition="start"
</Typography> />
<Typography variant="body2" color="text.secondary" gutterBottom> <Tab
{clientInfo.description} icon={<Security />}
</Typography> label="授权管理"
<Box mt={2}> iconPosition="start"
<Typography variant="body2" color="text.secondary" gutterBottom> />
请求的权限 </Tabs>
</Paper>
{/* 客户端管理标签页 */}
{activeTab === 0 && (
<Grid container spacing={3}>
{/* 用户信息卡片 */}
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
用户信息
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Person sx={{ mr: 2, color: 'primary.main' }} />
<Box>
<Typography variant="body2" color="text.secondary">
用户名
</Typography>
<Typography variant="body1">
{user?.username}
</Typography> </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> </Box>
</CardContent> </Box>
</Card>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Email sx={{ mr: 2, color: 'primary.main' }} />
<Box>
<Typography variant="body2" color="text.secondary">
邮箱
</Typography>
<Typography variant="body1">
{user?.email}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarToday sx={{ mr: 2, color: 'primary.main' }} />
<Box>
<Typography variant="body2" color="text.secondary">
注册时间
</Typography>
<Typography variant="body1">
{user?.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '未知'}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* 用户信息 */} {/* OAuth客户端管理 */}
<Card variant="outlined" sx={{ mb: 3 }}> <Grid item xs={12} md={8}>
<CardContent> <Card>
<Typography variant="h6" gutterBottom> <CardContent>
您的账户信息 <Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h6">
OAuth客户端
</Typography> </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 <Button
fullWidth
variant="outlined"
color="error"
startIcon={<Cancel />}
onClick={handleDeny}
disabled={loading}
>
拒绝
</Button>
</Grid>
<Grid item xs={6}>
<Button
fullWidth
variant="contained" variant="contained"
startIcon={<CheckCircle />} startIcon={<Add />}
onClick={handleAuthorize} onClick={() => setOpenCreateDialog(true)}
disabled={loading}
> >
{loading ? '授权中...' : '授权'} 创建客户端
</Button> </Button>
</Grid> </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>
)}
</CardContent>
</Card>
</Grid>
</Grid>
)}
{/* 授权管理标签页 */}
{activeTab === 1 && clientInfo && (
<Container maxWidth="sm">
<Paper elevation={3} sx={{ p: 4 }}>
<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>
{/* 应用信息 */}
<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>
</> <Grid item xs={6}>
)} <Button
</Paper> fullWidth
variant="contained"
startIcon={<CheckCircle />}
onClick={handleAuthorize}
disabled={loading}
>
{loading ? '授权中...' : '授权'}
</Button>
</Grid>
</Grid>
</Paper>
</Container>
)}
{/* 创建客户端对话框 */}
<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>
{/* 底部操作按钮 */}
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button
variant="outlined"
onClick={() => navigate('/dashboard')}
>
返回个人中心
</Button>
<Button
variant="outlined"
color="error"
onClick={handleLogout}
startIcon={<Logout />}
>
退出登录
</Button>
</Box> </Box>
</Container> </Container>
) )

133
test-rate-limit.js Normal file
View File

@ -0,0 +1,133 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000';
// 测试速率限制的函数
async function testRateLimit(endpoint, method = 'GET', data = null, description) {
console.log(`\n测试 ${description} 的速率限制...`);
try {
const config = {
method,
url: `${BASE_URL}${endpoint}`,
headers: {
'Content-Type': 'application/json'
}
};
if (data) {
config.data = data;
}
const response = await axios(config);
console.log(`✅ 请求成功: ${response.status}`);
return true;
} catch (error) {
if (error.response?.status === 429) {
console.log(`⏰ 速率限制触发: ${error.response.data.message}`);
return false;
} else {
console.log(`❌ 请求失败: ${error.response?.data?.message || error.message}`);
return false;
}
}
}
// 测试登录速率限制
async function testLoginRateLimit() {
console.log('\n=== 测试登录速率限制 ===');
for (let i = 1; i <= 7; i++) {
const success = await testRateLimit(
'/api/auth/login',
'POST',
{
username: `testuser${i}`,
password: 'testpass123'
},
`登录尝试 ${i}`
);
if (!success) {
console.log('✅ 登录速率限制正常工作');
break;
}
if (i === 6) {
console.log('⚠️ 登录速率限制可能未生效');
}
}
}
// 测试OAuth授权速率限制
async function testOAuthRateLimit() {
console.log('\n=== 测试OAuth授权速率限制 ===');
for (let i = 1; i <= 5; i++) {
const success = await testRateLimit(
'/api/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:3000/callback&scope=read&state=test',
'GET',
null,
`OAuth授权请求 ${i}`
);
if (!success) {
console.log('✅ OAuth授权速率限制正常工作');
break;
}
if (i === 4) {
console.log('⚠️ OAuth授权速率限制可能未生效');
}
}
}
// 测试通用速率限制
async function testGeneralRateLimit() {
console.log('\n=== 测试通用速率限制 ===');
for (let i = 1; i <= 105; i++) {
const success = await testRateLimit(
'/health',
'GET',
null,
`健康检查请求 ${i}`
);
if (!success) {
console.log('✅ 通用速率限制正常工作');
break;
}
if (i === 101) {
console.log('⚠️ 通用速率限制可能未生效');
}
}
}
// 主测试函数
async function runTests() {
console.log('🚀 开始测试速率限制功能...');
try {
await testGeneralRateLimit();
await testLoginRateLimit();
await testOAuthRateLimit();
console.log('\n✅ 速率限制测试完成');
} catch (error) {
console.error('❌ 测试过程中发生错误:', error.message);
}
}
// 运行测试
if (require.main === module) {
runTests();
}
module.exports = {
testRateLimit,
testLoginRateLimit,
testOAuthRateLimit,
testGeneralRateLimit
};