All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net> Reviewed-on: #37 Co-authored-by: AI Agent <ai-agent@campbellwireless.net> Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
import { env } from '$env/dynamic/private';
|
|
import argon2 from 'argon2';
|
|
import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js';
|
|
|
|
export interface LocalAuthUser {
|
|
id: string;
|
|
name: string;
|
|
email?: string | null;
|
|
}
|
|
|
|
export type LocalAuthResult =
|
|
| { status: 'success'; user: LocalAuthUser }
|
|
| { status: 'invalid' | 'locked' | 'disabled' };
|
|
|
|
interface AttemptState {
|
|
count: number;
|
|
firstAttemptAt: number;
|
|
lockedUntil?: number;
|
|
}
|
|
|
|
const attemptsByIdentifier = new Map<string, AttemptState>();
|
|
const attemptsByIp = new Map<string, AttemptState>();
|
|
let dummyHashPromise: Promise<string> | null = null;
|
|
|
|
const getEnvValue = (key: keyof typeof env): string | undefined => {
|
|
return process.env[key] ?? env[key];
|
|
};
|
|
|
|
const getNumberEnv = (value: string | undefined, fallback: number): number => {
|
|
const parsed = Number.parseInt(value ?? '', 10);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
};
|
|
|
|
const getLocalAuthConfig = () => {
|
|
return {
|
|
enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true',
|
|
maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5),
|
|
windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000,
|
|
lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000
|
|
};
|
|
};
|
|
|
|
const normalizeIdentifier = (value: string): string => value.trim().toLowerCase();
|
|
const normalizePassword = (value: string): string => value.trim();
|
|
|
|
const normalizeIp = (value?: string | null): string => {
|
|
if (!value) return 'unknown';
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return 'unknown';
|
|
return trimmed.split(',')[0]?.trim() || 'unknown';
|
|
};
|
|
|
|
const getDummyHash = async (): Promise<string> => {
|
|
if (!dummyHashPromise) {
|
|
dummyHashPromise = hashLocalPassword('invalid-password-placeholder');
|
|
}
|
|
return dummyHashPromise;
|
|
};
|
|
|
|
const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => {
|
|
if (!state) return { count: 0, firstAttemptAt: now };
|
|
if (state.lockedUntil && state.lockedUntil <= now) {
|
|
return { count: 0, firstAttemptAt: now };
|
|
}
|
|
if (now - state.firstAttemptAt > windowMs) {
|
|
return { count: 0, firstAttemptAt: now };
|
|
}
|
|
return { ...state };
|
|
};
|
|
|
|
const isLocked = (state: AttemptState, now: number): boolean => {
|
|
return Boolean(state.lockedUntil && state.lockedUntil > now);
|
|
};
|
|
|
|
const recordFailure = (
|
|
map: Map<string, AttemptState>,
|
|
key: string,
|
|
now: number,
|
|
windowMs: number,
|
|
lockoutMs: number,
|
|
maxAttempts: number
|
|
): void => {
|
|
const state = getEffectiveState(map.get(key), now, windowMs);
|
|
const nextCount = state.count + 1;
|
|
const nextState: AttemptState = {
|
|
count: nextCount,
|
|
firstAttemptAt: state.firstAttemptAt
|
|
};
|
|
if (nextCount >= maxAttempts) {
|
|
nextState.lockedUntil = now + lockoutMs;
|
|
}
|
|
map.set(key, nextState);
|
|
};
|
|
|
|
const clearAttemptState = (map: Map<string, AttemptState>, key: string): void => {
|
|
map.delete(key);
|
|
};
|
|
|
|
export function resetLocalAuthRateLimits(): void {
|
|
attemptsByIdentifier.clear();
|
|
attemptsByIp.clear();
|
|
}
|
|
|
|
export async function verifyLocalCredentials(
|
|
identifierInput: string,
|
|
passwordInput: string,
|
|
ipAddress?: string | null
|
|
): Promise<LocalAuthResult> {
|
|
const config = getLocalAuthConfig();
|
|
if (!config.enabled) return { status: 'disabled' };
|
|
|
|
const identifier = normalizeIdentifier(identifierInput ?? '');
|
|
const password = normalizePassword(passwordInput ?? '');
|
|
const identifierKey = identifier || 'unknown';
|
|
const ipKey = normalizeIp(ipAddress);
|
|
const now = Date.now();
|
|
|
|
const identifierState = getEffectiveState(
|
|
attemptsByIdentifier.get(identifierKey),
|
|
now,
|
|
config.windowMs
|
|
);
|
|
const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs);
|
|
const locked = isLocked(identifierState, now) || isLocked(ipState, now);
|
|
|
|
const record = identifier ? getLocalCredentialByIdentifier(identifier) : null;
|
|
const passwordHash = record?.passwordHash ?? (await getDummyHash());
|
|
const passwordMatches = await argon2.verify(passwordHash, password);
|
|
|
|
if (!locked && record && passwordMatches) {
|
|
clearAttemptState(attemptsByIdentifier, identifierKey);
|
|
clearAttemptState(attemptsByIp, ipKey);
|
|
return {
|
|
status: 'success',
|
|
user: {
|
|
id: record.userId,
|
|
name: record.fullName || record.username,
|
|
email: record.email ?? undefined
|
|
}
|
|
};
|
|
}
|
|
|
|
recordFailure(
|
|
attemptsByIdentifier,
|
|
identifierKey,
|
|
now,
|
|
config.windowMs,
|
|
config.lockoutMs,
|
|
config.maxAttempts
|
|
);
|
|
recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts);
|
|
|
|
return { status: locked ? 'locked' : 'invalid' };
|
|
}
|