♻️ Centralized data models (wip)

This commit is contained in:
2025-09-27 14:09:28 +08:00
parent 51b6f7309e
commit e70d8371f8
206 changed files with 1352 additions and 2128 deletions

View File

@@ -1,50 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using NodaTime.Serialization.Protobuf;
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; }
public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
{
return new DysonNetwork.Shared.Proto.ApiKey
{
Id = Id.ToString(),
Label = Label,
AccountId = AccountId.ToString(),
SessionId = SessionId.ToString(),
Key = Key,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}
public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
{
return new ApiKey
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
SessionId = Guid.Parse(proto.SessionId),
Label = proto.Label,
Key = proto.Key,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
}

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Localization;
using AccountAuthFactor = DysonNetwork.Pass.Account.AccountAuthFactor;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth;

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -76,10 +77,10 @@ public class AuthService(
return totalRequiredSteps;
}
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
public async Task<SnAuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
Guid? customAppId = null)
{
var challenge = new AuthChallenge
var challenge = new SnAuthChallenge
{
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
@@ -89,7 +90,7 @@ public class AuthService(
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
};
var session = new AuthSession
var session = new SnAuthSession
{
AccountId = account.Id,
CreatedAt = time,
@@ -105,7 +106,7 @@ public class AuthService(
return session;
}
public async Task<AuthClient> GetOrCreateDeviceAsync(
public async Task<SnAuthClient> GetOrCreateDeviceAsync(
Guid accountId,
string deviceId,
string? deviceName = null,
@@ -114,7 +115,7 @@ public class AuthService(
{
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
if (device is not null) return device;
device = new AuthClient
device = new SnAuthClient
{
Platform = platform,
DeviceId = deviceId,
@@ -181,7 +182,7 @@ public class AuthService(
}
}
public string CreateToken(AuthSession session)
public string CreateToken(SnAuthSession session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
@@ -199,7 +200,7 @@ public class AuthService(
/// <param name="challenge">Completed challenge</param>
/// <returns>Signed compact token</returns>
/// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
public async Task<string> CreateSessionAndIssueToken(AuthChallenge challenge)
public async Task<string> CreateSessionAndIssueToken(SnAuthChallenge challenge)
{
if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed.");
@@ -210,7 +211,7 @@ public class AuthService(
throw new ArgumentException("Session already exists for this challenge.");
var now = SystemClock.Instance.GetCurrentInstant();
var session = new AuthSession
var session = new SnAuthSession
{
LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)),
@@ -256,7 +257,7 @@ public class AuthService(
return $"{payloadBase64}.{signatureBase64}";
}
public async Task<bool> ValidateSudoMode(AuthSession session, string? pinCode)
public async Task<bool> ValidateSudoMode(SnAuthSession session, string? pinCode)
{
// Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo";
@@ -319,7 +320,7 @@ public class AuthService(
return factor.VerifyPassword(pinCode);
}
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
public async Task<SnApiKey?> GetApiKey(Guid id, Guid? accountId = null)
{
var key = await db.ApiKeys
.Include(e => e.Session)
@@ -329,13 +330,13 @@ public class AuthService(
return key;
}
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
{
var key = new ApiKey
var key = new SnApiKey
{
AccountId = accountId,
Label = label,
Session = new AuthSession
Session = new SnAuthSession
{
AccountId = accountId,
ExpiredAt = expiredAt
@@ -348,7 +349,7 @@ public class AuthService(
return key;
}
public async Task<string> IssueApiKeyToken(ApiKey key)
public async Task<string> IssueApiKeyToken(SnApiKey key)
{
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(key.Session);
@@ -357,14 +358,14 @@ public class AuthService(
return tk;
}
public async Task RevokeApiKeyToken(ApiKey key)
public async Task RevokeApiKeyToken(SnApiKey key)
{
db.Remove(key);
db.Remove(key.Session);
await db.SaveChangesAsync();
}
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
public async Task<SnApiKey> RotateApiKeyToken(SnApiKey key)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
@@ -372,7 +373,7 @@ public class AuthService(
var oldSessionId = key.SessionId;
// Create new session
var newSession = new AuthSession
var newSession = new SnAuthSession
{
AccountId = key.AccountId,
ExpiredAt = key.Session?.ExpiredAt

View File

@@ -1,133 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Pass.Auth;
public class AuthSession : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { 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(),
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
Account = Account.ToProtoValue(),
ChallengeId = ChallengeId.ToString(),
Challenge = Challenge?.ToProtoValue(),
AppId = AppId?.ToString()
};
}
public enum ChallengeType
{
Login,
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public enum ClientPlatform
{
Unidentified,
Web,
Ios,
Android,
MacOs,
Windows,
Linux
}
public class AuthChallenge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? ExpiredAt { get; set; }
public int StepRemain { get; set; }
public int StepTotal { get; set; }
public int FailedAttempts { get; set; }
public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; }
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid? ClientId { get; set; }
public AuthClient? Client { get; set; } = null!;
public AuthChallenge Normalize()
{
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
return this;
}
public Shared.Proto.AuthChallenge ToProtoValue() => new()
{
Id = Id.ToString(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
StepRemain = StepRemain,
StepTotal = StepTotal,
FailedAttempts = FailedAttempts,
Type = (Shared.Proto.ChallengeType)Type,
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
Audiences = { Audiences },
Scopes = { Scopes },
IpAddress = IpAddress,
UserAgent = UserAgent,
DeviceId = Client!.DeviceId,
Nonce = Nonce,
AccountId = AccountId.ToString()
};
}
public class AuthClient : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public ClientPlatform Platform { get; set; } = ClientPlatform.Unidentified;
[MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
[MaxLength(1024)] public string? DeviceLabel { get; set; }
[MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
}
public class AuthClientWithChallenge : AuthClient
{
public List<AuthChallenge> Challenges { get; set; } = [];
public static AuthClientWithChallenge FromClient(AuthClient client)
{
return new AuthClientWithChallenge
{
Id = client.Id,
Platform = client.Platform,
DeviceName = client.DeviceName,
DeviceLabel = client.DeviceLabel,
DeviceId = client.DeviceId,
AccountId = client.AccountId,
};
}
}

View File

@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth;
@@ -7,7 +8,7 @@ public class CompactTokenService(IConfiguration config)
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(AuthSession session)
public string CreateToken(SnAuthSession session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(_privateKeyPath);

View File

@@ -8,10 +8,10 @@ using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers;
@@ -98,9 +98,9 @@ public class OidcProviderController(
var clientInfo = new ClientInfoResponse
{
ClientId = Guid.Parse(client.Id),
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Picture = client.Picture is not null ? SnCloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Background = client.Background is not null
? CloudFileReferenceObject.FromProtoValue(client.Background)
? SnCloudFileReferenceObject.FromProtoValue(client.Background)
: null,
ClientName = client.Name,
HomeUri = client.Links.HomePage,
@@ -304,7 +304,7 @@ public class OidcProviderController(
public async Task<IActionResult> GetUserInfo()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge?.Scopes ?? [];

View File

@@ -1,13 +1,13 @@
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public SnCloudFileReferenceObject? Picture { get; set; }
public SnCloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }

View File

@@ -6,6 +6,7 @@ using DysonNetwork.Pass.Auth.OidcProvider.Models;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -68,7 +69,7 @@ public class OidcController(
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<AuthChallenge>> AppleMobileLogin(
public async Task<ActionResult<SnAuthChallenge>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request
)
{

View File

@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
@@ -187,7 +188,7 @@ public abstract class OidcService(
/// Creates a challenge and session for an authenticated user
/// Also creates or updates the account connection
/// </summary>
public async Task<AuthChallenge> CreateChallengeForUserAsync(
public async Task<SnAuthChallenge> CreateChallengeForUserAsync(
OidcUserInfo userInfo,
Account.Account account,
HttpContext request,
@@ -219,7 +220,7 @@ public abstract class OidcService(
// Create a challenge that's already completed
var now = SystemClock.Instance.GetCurrentInstant();
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
var challenge = new AuthChallenge
var challenge = new SnAuthChallenge
{
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = await auth.DetectChallengeRisk(request.Request, account),

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -25,7 +26,7 @@ public class TokenAuthService(
/// <param name="token">Incoming token string</param>
/// <param name="ipAddress">Client IP address, for logging purposes</param>
/// <returns>(Valid, Session, Message)</returns>
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
public async Task<(bool Valid, SnAuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
{
try
{
@@ -63,7 +64,7 @@ public class TokenAuthService(
// Try cache first
var cacheKey = $"{AuthCacheConstants.Prefix}{sessionId}";
var session = await cache.GetAsync<AuthSession>(cacheKey);
var session = await cache.GetAsync<SnAuthSession>(cacheKey);
if (session is not null)
{
logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey);