♻️ Centralized data models (wip)
This commit is contained in:
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
@@ -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 ?? [];
|
||||
|
@@ -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; }
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
)
|
||||
{
|
||||
|
@@ -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),
|
||||
|
@@ -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);
|
||||
|
Reference in New Issue
Block a user