Files
jobtrackingapp/JobTrackerApi/Services/TrustedDeviceService.cs
T

114 lines
4.9 KiB
C#

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<bool> 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;
// SQLite/Pomelo cannot translate DateTimeOffset relational comparisons (>) to SQL, so the
// expiry check has to happen after materializing the row -- fine here since the equality
// filters (UserId, TokenHash) already narrow this to at most one row.
var match = await db.TrustedDevices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash, cancellationToken);
if (match is null || match.ExpiresAtUtc <= now) return false;
match.LastSeenAtUtc = now;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public static async Task IssueAsync(JobTrackerContext db, HttpRequest request, HttpResponse response, string userId, bool secureCookies, 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);
response.Cookies.Append(AuthSessionOptions.TrustedDeviceCookieName, token, AuthSessionOptions.BuildTrustedDeviceCookie(secureCookies));
}
public static void ClearCookie(HttpResponse response, bool secureCookies)
{
response.Cookies.Delete(AuthSessionOptions.TrustedDeviceCookieName, AuthSessionOptions.BuildExpiredTrustedDeviceCookie(secureCookies));
}
// 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}";
}
}