fix: rate limit standalone login attempts
This commit is contained in:
parent
94308e9e5c
commit
29428cee08
2 changed files with 83 additions and 1 deletions
|
|
@ -87,6 +87,13 @@ interface SessionBody {
|
|||
|
||||
const sessionCookieName = '__Host-krona_staff_session';
|
||||
const sessionMaxAgeSeconds = 8 * 60 * 60;
|
||||
const loginRateLimitWindowMs = 60_000;
|
||||
const loginRateLimitMaxFailures = 10;
|
||||
|
||||
interface LoginRateLimitEntry {
|
||||
failures: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
export async function registerPortalRoutes(
|
||||
api: FastifyInstance,
|
||||
|
|
@ -98,6 +105,7 @@ export async function registerPortalRoutes(
|
|||
? new PlatformIdentityAdapter()
|
||||
: new StandaloneIdentityAdapter(config);
|
||||
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
|
||||
const loginRateLimit = new Map<string, LoginRateLimitEntry>();
|
||||
|
||||
const vapidKeys = resolveVapidKeys(config);
|
||||
const pushDispatcher = new PushDispatcher({
|
||||
|
|
@ -243,15 +251,33 @@ export async function registerPortalRoutes(
|
|||
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
|
||||
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')
|
||||
? 'manager'
|
||||
: context.permissions.has('staff:attendance:read_own')
|
||||
? 'employee'
|
||||
: null;
|
||||
if (!role) {
|
||||
recordLoginFailure(loginRateLimit, rateLimitKey);
|
||||
return reply.forbidden('Ключ не подходит для входа в кабинет.');
|
||||
}
|
||||
loginRateLimit.delete(rateLimitKey);
|
||||
reply
|
||||
.header('cache-control', 'no-store')
|
||||
.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(
|
||||
baseIdentity: IdentityPort,
|
||||
config: AppConfig,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,33 @@ describe('attendance portal', () => {
|
|||
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 () => {
|
||||
const { database, calls } = fakeDatabase([[], [], []]);
|
||||
const app = await buildApp({
|
||||
|
|
|
|||
Loading…
Reference in a new issue