fix: rate limit standalone login attempts
Some checks failed
ci / verify (push) Has been cancelled
ci / format (push) Has been cancelled
ci / e2e (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / docker-build (push) Has been cancelled

This commit is contained in:
Ochenstarik 2026-09-29 13:00:57 +07:00
parent 94308e9e5c
commit 29428cee08
2 changed files with 83 additions and 1 deletions

View file

@ -87,6 +87,13 @@ interface SessionBody {
const sessionCookieName = '__Host-krona_staff_session'; const sessionCookieName = '__Host-krona_staff_session';
const sessionMaxAgeSeconds = 8 * 60 * 60; const sessionMaxAgeSeconds = 8 * 60 * 60;
const loginRateLimitWindowMs = 60_000;
const loginRateLimitMaxFailures = 10;
interface LoginRateLimitEntry {
failures: number;
resetAt: number;
}
export async function registerPortalRoutes( export async function registerPortalRoutes(
api: FastifyInstance, api: FastifyInstance,
@ -98,6 +105,7 @@ export async function registerPortalRoutes(
? new PlatformIdentityAdapter() ? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config); : new StandaloneIdentityAdapter(config);
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database); const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
const loginRateLimit = new Map<string, LoginRateLimitEntry>();
const vapidKeys = resolveVapidKeys(config); const vapidKeys = resolveVapidKeys(config);
const pushDispatcher = new PushDispatcher({ const pushDispatcher = new PushDispatcher({
@ -243,15 +251,33 @@ export async function registerPortalRoutes(
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
return reply.serviceUnavailable('Вход администратора ещё не настроен.'); return reply.serviceUnavailable('Вход администратора ещё не настроен.');
} }
const context = await resolveToken(identity, request.body.access_token, request); const rateLimitKey = request.ip;
const retryAfter = getLoginRetryAfter(loginRateLimit, rateLimitKey);
if (retryAfter > 0) {
return reply.code(429).header('retry-after', retryAfter).send({
statusCode: 429,
error: 'Too Many Requests',
message: 'Слишком много неудачных попыток входа. Повторите позже.',
});
}
let context: RequestContext;
try {
context = await resolveToken(identity, request.body.access_token, request);
} catch (error) {
recordLoginFailure(loginRateLimit, rateLimitKey);
throw error;
}
const role = context.permissions.has('staff:attendance:manage') const role = context.permissions.has('staff:attendance:manage')
? 'manager' ? 'manager'
: context.permissions.has('staff:attendance:read_own') : context.permissions.has('staff:attendance:read_own')
? 'employee' ? 'employee'
: null; : null;
if (!role) { if (!role) {
recordLoginFailure(loginRateLimit, rateLimitKey);
return reply.forbidden('Ключ не подходит для входа в кабинет.'); return reply.forbidden('Ключ не подходит для входа в кабинет.');
} }
loginRateLimit.delete(rateLimitKey);
reply reply
.header('cache-control', 'no-store') .header('cache-control', 'no-store')
.header( .header(
@ -1249,6 +1275,35 @@ export async function registerPortalRoutes(
}); });
} }
function getLoginRetryAfter(
entries: Map<string, LoginRateLimitEntry>,
key: string,
now = Date.now(),
): number {
const entry = entries.get(key);
if (!entry) return 0;
if (entry.resetAt <= now) {
entries.delete(key);
return 0;
}
return entry.failures >= loginRateLimitMaxFailures
? Math.max(1, Math.ceil((entry.resetAt - now) / 1000))
: 0;
}
function recordLoginFailure(
entries: Map<string, LoginRateLimitEntry>,
key: string,
now = Date.now(),
) {
const entry = entries.get(key);
if (!entry || entry.resetAt <= now) {
entries.set(key, { failures: 1, resetAt: now + loginRateLimitWindowMs });
return;
}
entry.failures += 1;
}
function withStandaloneEmployeeAccess( function withStandaloneEmployeeAccess(
baseIdentity: IdentityPort, baseIdentity: IdentityPort,
config: AppConfig, config: AppConfig,

View file

@ -98,6 +98,33 @@ describe('attendance portal', () => {
await app.close(); await app.close();
}); });
it('rate-limits repeated failed browser logins', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
let response;
for (let attempt = 0; attempt < 10; attempt += 1) {
response = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: 'x'.repeat(64) },
});
expect(response.statusCode).toBe(401);
}
response = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: 'x'.repeat(64) },
});
expect(response.statusCode).toBe(429);
expect(response.headers['retry-after']).toBeDefined();
await app.close();
});
it('scopes an employee portal to the linked Core user', async () => { it('scopes an employee portal to the linked Core user', async () => {
const { database, calls } = fakeDatabase([[], [], []]); const { database, calls } = fakeDatabase([[], [], []]);
const app = await buildApp({ const app = await buildApp({