Add login security retention and legal terms

This commit is contained in:
JB
2026-07-25 03:10:56 -04:00
parent 78dc5dcc98
commit 008d295ffb
16 changed files with 196 additions and 36 deletions
+61 -5
View File
@@ -17,13 +17,23 @@ export type AuthUser = {
avatarUrl: string;
};
type LoginRequestContext = {
request?: Request;
getClientAddress?: () => string;
failureReason?: string | null;
credentialId?: string | null;
sessionId?: string | null;
riskFlags?: string[] | null;
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createSession(cookies: Cookies, userId: string): Promise<void> {
export async function createSession(cookies: Cookies, userId: string): Promise<string> {
const database = await getDatabase();
const token = randomBytes(32).toString('base64url');
const sessionId = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const insert = await database.prepare(
@@ -31,7 +41,7 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
);
await insert.run(
randomUUID(),
sessionId,
userId,
hashToken(token),
expiresAt.toISOString(),
@@ -45,12 +55,53 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
secure: !dev,
maxAge: SESSION_SECONDS
});
return sessionId;
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean): Promise<void> {
function describeClient(userAgent: string) {
const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' :
/Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other';
const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' :
/iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' :
/Linux/.test(userAgent) ? 'Linux' : 'Other';
const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop';
return { clientName, operatingSystem, deviceType };
}
export async function purgeExpiredLoginMetadata(): Promise<void> {
const database = await getDatabase();
await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString());
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await (await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL,
credential_id=NULL,session_id=NULL,sensitive_purged_at=?
WHERE created_at<? AND sensitive_purged_at IS NULL`)).run(now, cutoff);
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean, context: LoginRequestContext = {}): Promise<void> {
const database = await getDatabase();
const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
const headers = context.request?.headers;
const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country');
const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
const client = describeClient(userAgent ?? '');
const riskFlags = context.riskFlags ?? (!succeeded
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
: []);
let ipAddress: string | null = null;
try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ }
await (await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
client_name,operating_system,device_type,location_country,location_region,location_city,
failure_reason,risk_flags,credential_id,session_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent,
client.clientName, client.operatingSystem, client.deviceType,
locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null,
context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null,
context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString()
);
await purgeExpiredLoginMetadata();
}
export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null> {
@@ -107,6 +158,11 @@ export async function destroySession(cookies: Cookies): Promise<void> {
if (token) {
const database = await getDatabase();
const session = await (await database.prepare('SELECT id FROM sessions WHERE token_hash=?')).get(hashToken(token));
if (session?.id) {
await (await database.prepare('UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?'))
.run(new Date().toISOString(), 'user_logout', session.id);
}
const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?');
await statement.run(hashToken(token));
}