✨ API Keys
This commit is contained in:
@@ -38,6 +38,7 @@ public class AppDatabase(
|
||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||
public DbSet<AuthClient> AuthClients { get; set; } = null!;
|
||||
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||
|
19
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
19
DysonNetwork.Pass/Auth/ApiKey.cs
Normal 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; }
|
||||
}
|
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal 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();
|
||||
}
|
||||
}
|
@@ -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('_', '/');
|
||||
|
||||
|
@@ -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()
|
||||
};
|
||||
}
|
||||
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user