114 lines
4.9 KiB
C#
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}";
|
|
}
|
|
}
|