安全加固:增加后台登录 IP 限流并记录验证结果
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
|
||||
|
||||
test('createRateLimitMiddleware allows requests within the window', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: 'test:allow',
|
||||
windowMs: 60_000,
|
||||
max: 2,
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
let nextCount = 0
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1
|
||||
}
|
||||
|
||||
limiter(req, res, next)
|
||||
limiter(req, res, next)
|
||||
|
||||
assert.equal(nextCount, 2)
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
test('createRateLimitMiddleware blocks requests over the limit', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: 'test:block',
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
let nextCount = 0
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1
|
||||
}
|
||||
|
||||
limiter(req, res, next)
|
||||
limiter(req, res, next)
|
||||
|
||||
assert.equal(nextCount, 1)
|
||||
assert.equal(res.statusCode, 429)
|
||||
assert.equal(res.headers['Retry-After'], '60')
|
||||
assert.equal(res.body.errorCode, 'rate_limited')
|
||||
})
|
||||
|
||||
test('createRateLimitMiddleware supports custom limit responses', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: 'test:custom',
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
onLimit: (_req, res) => {
|
||||
res.status(200).json({ code: 429, message: 'limited' })
|
||||
},
|
||||
})
|
||||
const req = createMockRequest()
|
||||
const res = createMockResponse()
|
||||
|
||||
limiter(req, res, () => {})
|
||||
limiter(req, res, () => {})
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, { code: 429, message: 'limited' })
|
||||
})
|
||||
|
||||
function createMockRequest(): Request {
|
||||
return {
|
||||
ip: '127.0.0.1',
|
||||
originalUrl: '/test',
|
||||
headers: {},
|
||||
socket: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
} as Request
|
||||
}
|
||||
|
||||
function createMockResponse(): Response & {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
} {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
body: null as any,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value
|
||||
return this
|
||||
},
|
||||
status(statusCode: number) {
|
||||
this.statusCode = statusCode
|
||||
return this
|
||||
},
|
||||
json(body: any) {
|
||||
this.body = body
|
||||
return this
|
||||
},
|
||||
}
|
||||
|
||||
return res as Response & {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js'
|
||||
import {
|
||||
getAdminSessionSummary,
|
||||
loginAdmin,
|
||||
logoutAdmin,
|
||||
} from '../../services/admin/admin-auth-service.js'
|
||||
import {
|
||||
resolveClientIp,
|
||||
resolveClientLocation,
|
||||
resolveUserAgent,
|
||||
} from '../../services/admin/admin-login-log-service.js'
|
||||
import { createJsonHandler, extractBearerToken } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/auth/login',
|
||||
createRateLimitMiddleware({
|
||||
scope: 'admin:login',
|
||||
windowMs: 60_000,
|
||||
max: 10,
|
||||
key: getBodyFieldRateLimitKey('username'),
|
||||
}),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
loginAdmin(req.body?.username, req.body?.password, {
|
||||
ip: resolveClientIp(req),
|
||||
userAgent: resolveUserAgent(req),
|
||||
location: resolveClientLocation(req),
|
||||
}),
|
||||
{
|
||||
successMessage: '登录成功',
|
||||
errorMessage: '后台登录失败',
|
||||
scope: '[admin/auth/login]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/auth/session',
|
||||
createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取后台登录态失败',
|
||||
scope: '[admin/auth/session]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/auth/logout',
|
||||
createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), {
|
||||
successMessage: '已退出登录',
|
||||
errorMessage: '后台退出失败',
|
||||
scope: '[admin/auth/logout]',
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user