安全加固:增加后台登录 IP 限流并记录验证结果

This commit is contained in:
yml2213
2026-08-30 16:47:16 +08:00
parent d24c69141a
commit 6ca54e468e
10 changed files with 527 additions and 6 deletions
@@ -71,6 +71,31 @@ test('createRateLimitMiddleware supports custom limit responses', () => {
assert.deepEqual(res.body, { code: 429, message: 'limited' }) assert.deepEqual(res.body, { code: 429, message: 'limited' })
}) })
test('createRateLimitMiddleware can cap an IP across different accounts', () => {
resetRateLimitBucketsForTest()
const limiter = createRateLimitMiddleware({
scope: 'test:ip-wide',
windowMs: 60_000,
max: 2,
})
const first = createMockRequest()
const second = createMockRequest()
const third = createMockRequest()
const response = createMockResponse()
let nextCount = 0
const next: NextFunction = () => {
nextCount += 1
}
limiter(first, response, next)
limiter(second, response, next)
limiter(third, response, next)
assert.equal(nextCount, 2)
assert.equal(response.statusCode, 429)
})
function createMockRequest(): Request { function createMockRequest(): Request {
return { return {
ip: '127.0.0.1', ip: '127.0.0.1',
+15 -6
View File
@@ -15,14 +15,23 @@ import { createJsonHandler, extractBearerToken } from './session.js'
const router = Router() const router = Router()
const adminLoginIpRateLimit = createRateLimitMiddleware({
scope: 'admin:login:ip',
windowMs: 60_000,
max: 20,
})
const adminLoginAccountRateLimit = createRateLimitMiddleware({
scope: 'admin:login:account',
windowMs: 60_000,
max: 10,
key: getBodyFieldRateLimitKey('username'),
})
router.post( router.post(
'/auth/login', '/auth/login',
createRateLimitMiddleware({ adminLoginIpRateLimit,
scope: 'admin:login', adminLoginAccountRateLimit,
windowMs: 60_000,
max: 10,
key: getBodyFieldRateLimitKey('username'),
}),
createJsonHandler( createJsonHandler(
(req) => (req) =>
loginAdmin(req.body?.username, req.body?.password, { loginAdmin(req.body?.username, req.body?.password, {
+59
View File
@@ -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
@@ -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
}
}
+29
View File
@@ -0,0 +1,29 @@
diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts
--- a/apps/backend/src/routes/admin/auth.ts
+++ b/apps/backend/src/routes/admin/auth.ts
@@
const router = Router()
+
+const adminLoginIpRateLimit = createRateLimitMiddleware({
+ scope: 'admin:login:ip',
+ windowMs: 60_000,
+ max: 20,
+})
+
+const adminLoginAccountRateLimit = createRateLimitMiddleware({
+ scope: 'admin:login:account',
+ windowMs: 60_000,
+ max: 10,
+ key: getBodyFieldRateLimitKey('username'),
+})
router.post(
'/auth/login',
- createRateLimitMiddleware({
- scope: 'admin:login',
- windowMs: 60_000,
- max: 10,
- key: getBodyFieldRateLimitKey('username'),
- }),
+ adminLoginIpRateLimit,
+ adminLoginAccountRateLimit,
+68
View File
@@ -0,0 +1,68 @@
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()
const adminLoginIpRateLimit = createRateLimitMiddleware({
scope: 'admin:login:ip',
windowMs: 60_000,
max: 20,
})
const adminLoginAccountRateLimit = createRateLimitMiddleware({
scope: 'admin:login:account',
windowMs: 60_000,
max: 10,
key: getBodyFieldRateLimitKey('username'),
})
router.post(
'/auth/login',
adminLoginIpRateLimit,
adminLoginAccountRateLimit,
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
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?usage: ROLLBACK.sh <workspace-copy>}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
cp "$SCRIPT_DIR/BASELINE_admin_auth.ts" "$ROOT/apps/backend/src/routes/admin/auth.ts"
cp "$SCRIPT_DIR/BASELINE_rate-limit.test.ts" "$ROOT/apps/backend/src/middleware/rate-limit.test.ts"
printf 'restored admin auth route and rate-limit test in %s\n' "$ROOT"
+37
View File
@@ -0,0 +1,37 @@
Security review: admin login brute-force protection
Changed branch/field:
- apps/backend/src/routes/admin/auth.ts, POST /auth/login middleware chain
- Added admin:login:ip (20 requests / 60 seconds per client IP)
- Kept admin:login:account (10 requests / 60 seconds per client IP + username)
Artifacts:
- MODIFIED_FILE: /Users/yml/codes/order_site/security-artifacts/MODIFIED_FILE.ts
- DIFF_FILE: /Users/yml/codes/order_site/security-artifacts/DIFF_FILE.patch
- VERIFICATION.txt: /Users/yml/codes/order_site/security-artifacts/VERIFICATION.txt
- ROLLBACK.sh: /Users/yml/codes/order_site/security-artifacts/ROLLBACK.sh
BASELINE
Command: npm test -- --test-name-pattern='createRateLimitMiddleware'
Input: repository HEAD sources before the route/test edits
Literal result: tests 371; pass 369; fail 0; skipped 2; exit status 0
MODIFIED
Command: node --import tsx --test src/middleware/rate-limit.test.ts
Input: modified rate-limit middleware test source
Literal result: tests 4; pass 4; fail 0; skipped 0; exit status 0
Command: npm run check
Input: modified backend source tree
Literal result: format check passed; SQL guard passed; ESLint passed; TypeScript passed; tests 373; pass 371; fail 0; skipped 2; build completed; exit status 0
ROLLBACK
Command: security-artifacts/ROLLBACK.sh security-artifacts/rollback-copy
Input: independent copy containing MODIFIED_FILE.ts and BASELINE_rate-limit.test.ts
Literal result: restored admin auth route and rate-limit test in security-artifacts/rollback-copy; baseline admin auth SHA-256 d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff; exit status 0
Restored behavior/status: rollback-copy/apps/backend/src/routes/admin/auth.ts matches the baseline hash; the working source remains modified.
Original source hash before modification:
d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff
Modified copy hash:
c70ca222c13245a6e1714f1c90f3592f5c4fd3713a1ef3af5fb4073eb9b9db95
@@ -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