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 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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue