Add login security retention and legal terms
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user