✨ API Keys
This commit is contained in:
@@ -38,6 +38,7 @@ public class AppDatabase(
|
|||||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||||
public DbSet<AuthClient> AuthClients { 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<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||||
public DbSet<WalletPocket> WalletPockets { 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -318,6 +318,65 @@ public class AuthService(
|
|||||||
return factor.VerifyPassword(pinCode);
|
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
|
// Helper methods for Base64Url encoding/decoding
|
||||||
private static string Base64UrlEncode(byte[] data)
|
private static string Base64UrlEncode(byte[] data)
|
||||||
{
|
{
|
||||||
@@ -329,7 +388,7 @@ public class AuthService(
|
|||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
{
|
{
|
||||||
string padded = base64Url
|
var padded = base64Url
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace('_', '/');
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
@@ -12,26 +11,28 @@ namespace DysonNetwork.Pass.Auth;
|
|||||||
public class AuthSession : ModelBase
|
public class AuthSession : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
|
||||||
public Instant? LastGrantedAt { get; set; }
|
public Instant? LastGrantedAt { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[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 Guid? AppId { get; set; }
|
||||||
|
|
||||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||||
{
|
{
|
||||||
Id = Id.ToString(),
|
Id = Id.ToString(),
|
||||||
Label = Label,
|
|
||||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
Account = Account.ToProtoValue(),
|
Account = Account.ToProtoValue(),
|
||||||
ChallengeId = ChallengeId.ToString(),
|
ChallengeId = ChallengeId.ToString(),
|
||||||
Challenge = Challenge.ToProtoValue(),
|
Challenge = Challenge?.ToProtoValue(),
|
||||||
AppId = AppId?.ToString()
|
AppId = AppId?.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -13,7 +13,6 @@ import 'account.proto';
|
|||||||
// Represents a user session
|
// Represents a user session
|
||||||
message AuthSession {
|
message AuthSession {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
google.protobuf.StringValue label = 2;
|
|
||||||
optional google.protobuf.Timestamp last_granted_at = 3;
|
optional google.protobuf.Timestamp last_granted_at = 3;
|
||||||
optional google.protobuf.Timestamp expired_at = 4;
|
optional google.protobuf.Timestamp expired_at = 4;
|
||||||
string account_id = 5;
|
string account_id = 5;
|
||||||
|
Reference in New Issue
Block a user