Add local authentication option (#37)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
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>
This commit was merged in pull request #37.
This commit is contained in:
154
src/lib/server/local-auth.ts
Normal file
154
src/lib/server/local-auth.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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' };
|
||||
}
|
||||
Reference in New Issue
Block a user