Files
jobtrackingapp/JobTrackerApi/Services/TrustedDeviceService.cs
T
cesnimda b914630657 feat(auth): add trusted-device 30-day 2FA skip (backend)
Adds a "trust this device" option to the 2FA challenge: on success, mints a
random token (only its SHA-256 hash is stored), sets it as a new httpOnly,
Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController
checks that cookie for the exact signing-in user before gating on 2FA -- a
mismatched user, expired, or revoked device falls through to the normal 2FA
prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all
endpoints for managing trusted devices, scoped to the owning user.

Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects),
not EF migrations, matching this repo's established pattern.
2026-07-13 01:02:35 +02:00

112 lines
4.8 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;
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.
private 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}";
}
}