From 3b679d6134f539b31448ebe97338ebc0c87f25ee Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 20 Aug 2025 13:41:06 +0800 Subject: [PATCH] :sparkles: API Keys --- DysonNetwork.Pass/AppDatabase.cs | 1 + DysonNetwork.Pass/Auth/ApiKey.cs | 19 +++++ DysonNetwork.Pass/Auth/ApiKeyController.cs | 90 ++++++++++++++++++++++ DysonNetwork.Pass/Auth/AuthService.cs | 61 ++++++++++++++- DysonNetwork.Pass/Auth/AuthSession.cs | 13 ++-- DysonNetwork.Shared/Proto/auth.proto | 1 - 6 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 DysonNetwork.Pass/Auth/ApiKey.cs create mode 100644 DysonNetwork.Pass/Auth/ApiKeyController.cs diff --git a/DysonNetwork.Pass/AppDatabase.cs b/DysonNetwork.Pass/AppDatabase.cs index fed1f47..b74835e 100644 --- a/DysonNetwork.Pass/AppDatabase.cs +++ b/DysonNetwork.Pass/AppDatabase.cs @@ -38,6 +38,7 @@ public class AppDatabase( public DbSet AuthSessions { get; set; } = null!; public DbSet AuthChallenges { get; set; } = null!; public DbSet AuthClients { get; set; } = null!; + public DbSet ApiKeys { get; set; } = null!; public DbSet Wallets { get; set; } = null!; public DbSet WalletPockets { get; set; } = null!; diff --git a/DysonNetwork.Pass/Auth/ApiKey.cs b/DysonNetwork.Pass/Auth/ApiKey.cs new file mode 100644 index 0000000..c38ce4e --- /dev/null +++ b/DysonNetwork.Pass/Auth/ApiKey.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; + +namespace DysonNetwork.Pass.Auth; + +public class ApiKey : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Label { get; set; } = null!; + + public Guid AccountId { get; set; } + public Account.Account Account { get; set; } = null!; + public Guid SessionId { get; set; } + public AuthSession Session { get; set; } = null!; + + [NotMapped] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Key { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/ApiKeyController.cs b/DysonNetwork.Pass/Auth/ApiKeyController.cs new file mode 100644 index 0000000..744800d --- /dev/null +++ b/DysonNetwork.Pass/Auth/ApiKeyController.cs @@ -0,0 +1,90 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Auth; + +[ApiController] +[Route("/api/auth/keys")] +public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase +{ + [HttpGet] + [Authorize] + public async Task GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var query = db.ApiKeys + .Where(e => e.AccountId == currentUser.Id) + .AsQueryable(); + + var totalCount = await query.CountAsync(); + Response.Headers["X-Total"] = totalCount.ToString(); + + var keys = await query + .Skip(offset) + .Take(take) + .ToListAsync(); + return Ok(keys); + } + + [HttpGet("{id:guid}")] + [Authorize] + public async Task GetKey(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var key = await db.ApiKeys + .Where(e => e.AccountId == currentUser.Id) + .Where(e => e.Id == id) + .FirstOrDefaultAsync(); + if (key == null) return NotFound(); + return Ok(key); + } + + public class ApiKeyRequest + { + [MaxLength(1024)] public string? Label { get; set; } + public Instant? ExpiredAt { get; set; } + } + + [HttpPost] + [Authorize] + public async Task CreateKey([FromBody] ApiKeyRequest request) + { + if (string.IsNullOrWhiteSpace(request.Label)) + return BadRequest("Label is required"); + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt); + key.Key = await auth.IssueApiKeyToken(key); + return Ok(key); + } + + [HttpPost("{id:guid}/rotate")] + [Authorize] + public async Task RotateKey(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var key = await auth.GetApiKey(id, currentUser.Id); + if(key is null) return NotFound(); + key = await auth.RotateApiKeyToken(key); + key.Key = await auth.IssueApiKeyToken(key); + return Ok(key); + } + + [HttpDelete("{id:guid}")] + [Authorize] + public async Task DeleteKey(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var key = await auth.GetApiKey(id, currentUser.Id); + if(key is null) return NotFound(); + await auth.RevokeApiKeyToken(key); + return NoContent(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index baa928d..f9669fb 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -317,6 +317,65 @@ public class AuthService( return factor.VerifyPassword(pinCode); } + + public async Task GetApiKey(Guid id, Guid? accountId = null) + { + var key = await db.ApiKeys + .Include(e => e.Session) + .Where(e => e.Id == id) + .If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value)) + .FirstOrDefaultAsync(); + return key; + } + + public async Task CreateApiKey(Guid accountId, string label, Instant? expiredAt = null) + { + var key = new ApiKey + { + AccountId = accountId, + Label = label, + Session = new AuthSession + { + AccountId = accountId, + ExpiredAt = expiredAt + }, + }; + + db.ApiKeys.Add(key); + await db.SaveChangesAsync(); + + return key; + } + + public async Task IssueApiKeyToken(ApiKey key) + { + key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant(); + db.Update(key.Session); + await db.SaveChangesAsync(); + var tk = CreateToken(key.Session); + return tk; + } + + public async Task RevokeApiKeyToken(ApiKey key) + { + db.Remove(key); + db.Remove(key.Session); + await db.SaveChangesAsync(); + } + + public async Task RotateApiKeyToken(ApiKey key) + { + var originalSession = key.Session; + db.Remove(originalSession); + key.Session = new AuthSession + { + AccountId = key.AccountId, + ExpiredAt = originalSession.ExpiredAt + }; + db.Add(key.Session); + await db.SaveChangesAsync(); + return key; + } // Helper methods for Base64Url encoding/decoding private static string Base64UrlEncode(byte[] data) @@ -329,7 +388,7 @@ public class AuthService( private static byte[] Base64UrlDecode(string base64Url) { - string padded = base64Url + var padded = base64Url .Replace('-', '+') .Replace('_', '/'); diff --git a/DysonNetwork.Pass/Auth/AuthSession.cs b/DysonNetwork.Pass/Auth/AuthSession.cs index 00b27d1..8d4b157 100644 --- a/DysonNetwork.Pass/Auth/AuthSession.cs +++ b/DysonNetwork.Pass/Auth/AuthSession.cs @@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Shared.Data; -using Microsoft.EntityFrameworkCore; using NodaTime; using NodaTime.Serialization.Protobuf; using Point = NetTopologySuite.Geometries.Point; @@ -12,26 +11,28 @@ namespace DysonNetwork.Pass.Auth; public class AuthSession : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(1024)] public string? Label { get; set; } public Instant? LastGrantedAt { get; set; } public Instant? ExpiredAt { get; set; } public Guid AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; - public Guid ChallengeId { get; set; } - public AuthChallenge Challenge { get; set; } = null!; + + // When the challenge is null, indicates the session is for an API Key + public Guid? ChallengeId { get; set; } + public AuthChallenge? Challenge { get; set; } = null!; + + // Indicates the session is for an OIDC connection public Guid? AppId { get; set; } public Shared.Proto.AuthSession ToProtoValue() => new() { Id = Id.ToString(), - Label = Label, LastGrantedAt = LastGrantedAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(), AccountId = AccountId.ToString(), Account = Account.ToProtoValue(), ChallengeId = ChallengeId.ToString(), - Challenge = Challenge.ToProtoValue(), + Challenge = Challenge?.ToProtoValue(), AppId = AppId?.ToString() }; } diff --git a/DysonNetwork.Shared/Proto/auth.proto b/DysonNetwork.Shared/Proto/auth.proto index cb69ec4..74f3a6c 100644 --- a/DysonNetwork.Shared/Proto/auth.proto +++ b/DysonNetwork.Shared/Proto/auth.proto @@ -13,7 +13,6 @@ import 'account.proto'; // Represents a user session message AuthSession { string id = 1; - google.protobuf.StringValue label = 2; optional google.protobuf.Timestamp last_granted_at = 3; optional google.protobuf.Timestamp expired_at = 4; string account_id = 5;