API Keys

This commit is contained in:
2025-08-20 13:41:06 +08:00
parent ec44b51ab6
commit 3b679d6134
6 changed files with 177 additions and 8 deletions

View File

@@ -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; }
}

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

View File

@@ -317,6 +317,65 @@ public class AuthService(
return factor.VerifyPassword(pinCode);
}
public async Task<ApiKey?> 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<ApiKey> 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<string> 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<ApiKey> 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('_', '/');

View File

@@ -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()
};
}