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
+6
View File
@@ -42,3 +42,9 @@
</header>
{@render children()}
<footer class="site-footer">
<div><a class="brand footer-brand" href="/"><span class="brand-mark"><i></i><b>SHD</b></span><span>SHD<span class="accent">.build</span></span></a><p>Independent loadout intelligence for the agent community.</p></div>
<div class="footer-links"><a href="/terms">TERMS & PRIVACY</a><a href="/terms#retention">DATA RETENTION</a></div>
<p class="fan-disclaimer">UNOFFICIAL FAN SITE · NOT AFFILIATED WITH OR ENDORSED BY UBISOFT. TOM CLANCY’S THE DIVISION, THE DIVISION RESURGENCE, UBISOFT, AND RELATED INTELLECTUAL PROPERTY ARE OWNED BY UBISOFT AND/OR ITS LICENSORS.</p>
</footer>
+8 -4
View File
@@ -6,7 +6,8 @@ export async function softDeleteAccount(
database: Database,
userId: string,
deletedBy: string,
reason: string | null
reason: string | null,
purgeSecurityData = false
): Promise<boolean> {
const user = await (await database.prepare(`
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
@@ -20,7 +21,7 @@ export async function softDeleteAccount(
await (await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
)).run(userId, String(user.login_username ?? user.username), now, deletedBy, reason);
)).run(userId, purgeSecurityData ? deletedUsername : String(user.login_username ?? user.username), now, deletedBy, reason);
await (await database.prepare(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
@@ -38,6 +39,9 @@ export async function softDeleteAccount(
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId);
await deleteUploadedAvatar(database, userId);
if (purgeSecurityData) {
await (await database.prepare('DELETE FROM login_events WHERE user_id=?')).run(userId);
}
await (await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
)).run(
@@ -46,8 +50,8 @@ export async function softDeleteAccount(
'user',
userId,
'account.soft_deleted',
JSON.stringify({ username: user.username, displayName: user.display_name }),
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason }),
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason, securityDataPurged: purgeSecurityData }),
now
);
return true;
+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));
}