ApiKeyAuthenticationHandler.cs 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. using System.Security.Claims;
  2. using System.Security.Cryptography;
  3. using System.Text;
  4. using System.Text.Encodings.Web;
  5. using Application.Abstractions.Data;
  6. using Microsoft.AspNetCore.Authentication;
  7. using Microsoft.EntityFrameworkCore;
  8. using Microsoft.Extensions.Logging;
  9. using Microsoft.Extensions.Options;
  10. namespace Infrastructure.Authentication;
  11. /// <summary>
  12. /// Personal Access Token (PAT) Bearer scheme.
  13. /// Header: Authorization: Bearer dpot_pat_xxxxx
  14. /// </summary>
  15. public sealed class ApiKeyAuthenticationHandler(
  16. IOptionsMonitor<AuthenticationSchemeOptions> options,
  17. ILoggerFactory logger,
  18. UrlEncoder encoder,
  19. IAppDbContext db
  20. ) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
  21. {
  22. public const string SchemeName = "ApiKey";
  23. public const string TokenPrefix = "dpot_pat_";
  24. private readonly IAppDbContext _db = db;
  25. protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
  26. {
  27. if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
  28. {
  29. return AuthenticateResult.NoResult();
  30. }
  31. var auth = authHeader.ToString();
  32. if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
  33. {
  34. return AuthenticateResult.NoResult();
  35. }
  36. var token = auth.Substring("Bearer ".Length).Trim();
  37. if (!token.StartsWith(TokenPrefix, StringComparison.Ordinal))
  38. {
  39. // Not a PAT — let other schemes try.
  40. return AuthenticateResult.NoResult();
  41. }
  42. var hash = HashToken(token);
  43. var pat = await _db.ApiPersonalAccessToken
  44. .AsTracking()
  45. .FirstOrDefaultAsync(c => c.TokenHash == hash, Context.RequestAborted);
  46. if (pat is null)
  47. {
  48. return AuthenticateResult.Fail("Invalid API key.");
  49. }
  50. if (!pat.IsActive)
  51. {
  52. return AuthenticateResult.Fail("API key is revoked or expired.");
  53. }
  54. pat.TouchLastUsed();
  55. await _db.SaveChangesAsync(Context.RequestAborted);
  56. var claims = new List<Claim>
  57. {
  58. new(ClaimTypes.NameIdentifier, pat.OwnerMemberID.ToString()),
  59. new("token_id", pat.ID.ToString()),
  60. new("token_type", "pat"),
  61. new("scope", pat.ScopesCsv)
  62. };
  63. foreach (var s in pat.ScopesCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  64. {
  65. claims.Add(new Claim("scp", s));
  66. }
  67. var identity = new ClaimsIdentity(claims, Scheme.Name);
  68. var principal = new ClaimsPrincipal(identity);
  69. var ticket = new AuthenticationTicket(principal, Scheme.Name);
  70. return AuthenticateResult.Success(ticket);
  71. }
  72. public static string HashToken(string token)
  73. {
  74. var bytes = Encoding.UTF8.GetBytes(token);
  75. var hash = SHA256.HashData(bytes);
  76. return Convert.ToHexString(hash).ToLowerInvariant();
  77. }
  78. }