using System.Security.Cryptography; using System.Text; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; // "Trust this device for 30 days" -- lets a 2FA challenge be skipped on the same browser for a // month. Shared by AuthController (checks the cookie before gating on 2FA) and // TwoFactorController (issues/lists/revokes the cookie's backing row). Never stores the // plaintext token, only its SHA-256 hash. public static class TrustedDeviceService { private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30); // Only returns true (and thus skips 2FA) when the cookie's hash matches a non-expired row // for THIS SPECIFIC user -- UserId is part of the DB query itself, not a check applied // after the fact, so a trusted-device cookie minted for user A can never skip 2FA for user // B even if somehow presented on their request. public static async Task IsDeviceTrustedAsync(JobTrackerContext db, HttpRequest request, string userId, CancellationToken cancellationToken) { var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName]; if (string.IsNullOrWhiteSpace(token)) return false; var hash = HashToken(token); var now = DateTimeOffset.UtcNow; var match = await db.TrustedDevices .IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash && x.ExpiresAtUtc > now, cancellationToken); if (match is null) return false; match.LastSeenAtUtc = now; await db.SaveChangesAsync(cancellationToken); return true; } public static async Task IssueAsync(JobTrackerContext db, HttpRequest request, HttpResponse response, string userId, CancellationToken cancellationToken) { var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); var now = DateTimeOffset.UtcNow; db.TrustedDevices.Add(new TrustedDevice { UserId = userId, TokenHash = HashToken(token), DeviceLabel = DescribeUserAgent(request.Headers["User-Agent"].ToString()), CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.Add(Lifetime), }); await db.SaveChangesAsync(cancellationToken); var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); response.Cookies.Append(AuthSessionOptions.TrustedDeviceCookieName, token, AuthSessionOptions.BuildTrustedDeviceCookie(secure)); } public static void ClearCookie(HttpRequest request, HttpResponse response) { var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); response.Cookies.Delete(AuthSessionOptions.TrustedDeviceCookieName, AuthSessionOptions.BuildExpiredTrustedDeviceCookie(secure)); } // Used to flag "this device" in the trusted-devices list without ever sending a token or // hash to the client -- just a boolean per row. public static string? CurrentDeviceTokenHash(HttpRequest request) { var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName]; return string.IsNullOrWhiteSpace(token) ? null : HashToken(token); } public static string HashToken(string token) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token.Trim()))).ToLowerInvariant(); } private static readonly (string Needle, string Label)[] BrowserMarkers = { ("Edg/", "Edge"), ("OPR/", "Opera"), ("Chrome/", "Chrome"), ("Firefox/", "Firefox"), ("Safari/", "Safari"), }; private static readonly (string Needle, string Label)[] OsMarkers = { ("Windows", "Windows"), ("Mac OS X", "Mac"), ("iPhone", "iOS"), ("iPad", "iOS"), ("Android", "Android"), ("Linux", "Linux"), }; // ponytail: substring sniffing, not a real UA parser -- this only feeds a display label in // a security-settings list ("Chrome on Windows"), nothing security-relevant depends on it. // Public: also reused by AppSessionIssuer for UserSession device labels. public static string? DescribeUserAgent(string? userAgent) { if (string.IsNullOrWhiteSpace(userAgent)) return null; var browser = BrowserMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label; var os = OsMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label; if (browser is null && os is null) return userAgent.Length > 80 ? userAgent[..80] : userAgent; if (browser is null) return os; if (os is null) return browser; return $"{browser} on {os}"; } }