diff --git a/DysonNetwork.Common/Clients/FileReferenceServiceClient.cs b/DysonNetwork.Common/Clients/FileReferenceServiceClient.cs new file mode 100644 index 0000000..457dfaf --- /dev/null +++ b/DysonNetwork.Common/Clients/FileReferenceServiceClient.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DysonNetwork.Common.Interfaces; +using DysonNetwork.Common.Models; +using Microsoft.Extensions.Logging; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; + +namespace DysonNetwork.Common.Clients +{ + public class FileReferenceServiceClient : IFileReferenceServiceClient, IDisposable + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + public FileReferenceServiceClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _jsonOptions = new JsonSerializerOptions() + .ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + _jsonOptions.PropertyNameCaseInsensitive = true; + } + + public async Task CreateReferenceAsync( + string fileId, + string usage, + string resourceId, + Instant? expiredAt = null, + Duration? duration = null) + { + var request = new + { + FileId = fileId, + Usage = usage, + ResourceId = resourceId, + ExpiredAt = expiredAt, + Duration = duration + }; + + var content = new StringContent( + JsonSerializer.Serialize(request, _jsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync("api/filereferences", content); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var reference = await JsonSerializer.DeserializeAsync(stream, _jsonOptions); + return reference; + } + + public async Task GetReferenceAsync(string referenceId) + { + var response = await _httpClient.GetAsync($"api/filereferences/{referenceId}"); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var reference = await JsonSerializer.DeserializeAsync(stream, _jsonOptions); + return reference; + } + + public async Task> GetReferencesForResourceAsync(string resourceId, string? usage = null) + { + var url = $"api/filereferences/resource/{resourceId}"; + if (!string.IsNullOrEmpty(usage)) + { + url += $"?usage={Uri.EscapeDataString(usage)}"; + } + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var references = await JsonSerializer.DeserializeAsync>(stream, _jsonOptions); + return references ?? Array.Empty(); + } + + public async Task DeleteReferenceAsync(string referenceId) + { + var response = await _httpClient.DeleteAsync($"api/filereferences/{referenceId}"); + response.EnsureSuccessStatusCode(); + } + + public void Dispose() + { + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/DysonNetwork.Common/Clients/FileServiceClient.cs b/DysonNetwork.Common/Clients/FileServiceClient.cs new file mode 100644 index 0000000..cd77b77 --- /dev/null +++ b/DysonNetwork.Common/Clients/FileServiceClient.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using DysonNetwork.Common.Interfaces; +using DysonNetwork.Common.Models; +using Microsoft.Extensions.Logging; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; + +namespace DysonNetwork.Common.Clients +{ + public class FileServiceClient : IFileServiceClient, IDisposable + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + public FileServiceClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _jsonOptions = new JsonSerializerOptions().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + _jsonOptions.PropertyNameCaseInsensitive = true; + } + + public async Task GetFileAsync(string fileId) + { + var response = await _httpClient.GetAsync($"api/files/{fileId}"); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var file = await JsonSerializer.DeserializeAsync(stream, _jsonOptions); + return file; + } + + public async Task DownloadFileAsync(string fileId) + { + var response = await _httpClient.GetAsync($"api/files/{fileId}/download"); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(); + } + + public async Task UploadFileAsync(Stream fileStream, string fileName, string contentType, string? folderId = null) + { + using var content = new MultipartFormDataContent(); + var fileContent = new StreamContent(fileStream); + content.Add(fileContent, "file", fileName); + + if (!string.IsNullOrEmpty(folderId)) + { + content.Add(new StringContent(folderId), "folderId"); + } + + var response = await _httpClient.PostAsync("api/files/upload", content); + response.EnsureSuccessStatusCode(); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + var file = await JsonSerializer.DeserializeAsync(responseStream, _jsonOptions); + return file; + } + + public async Task DeleteFileAsync(string fileId) + { + var response = await _httpClient.DeleteAsync($"api/files/{fileId}"); + response.EnsureSuccessStatusCode(); + } + + public void Dispose() + { + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/DysonNetwork.Common/Models/AccountConnection.cs b/DysonNetwork.Common/Models/AccountConnection.cs new file mode 100644 index 0000000..ab74cb8 --- /dev/null +++ b/DysonNetwork.Common/Models/AccountConnection.cs @@ -0,0 +1,81 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using NodaTime; + +namespace DysonNetwork.Common.Models; + +/// +/// Represents a connection between an account and an authentication provider +/// +public class AccountConnection +{ + /// + /// Unique identifier for the connection + /// + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public string Id { get; set; } = null!; + + /// + /// The account ID this connection is associated with + /// + public string? AccountId { get; set; } + + /// + /// The authentication provider (e.g., "google", "github") + /// + [Required] + [MaxLength(50)] + public string Provider { get; set; } = null!; + + /// + /// The unique identifier for the user from the provider + /// + [Required] + [MaxLength(256)] + public string ProvidedIdentifier { get; set; } = null!; + + /// + /// Display name for the connection + /// + [MaxLength(100)] + public string? DisplayName { get; set; } + + /// + /// OAuth access token from the provider + /// + public string? AccessToken { get; set; } + + /// + /// OAuth refresh token from the provider (if available) + /// + public string? RefreshToken { get; set; } + + /// + /// When the access token expires (if available) + /// + public Instant? ExpiresAt { get; set; } + + /// + /// When the connection was first established + /// + public Instant CreatedAt { get; set; } + + /// + /// When the connection was last used + /// + public Instant? LastUsedAt { get; set; } + + /// + /// Additional metadata about the connection + /// + [Column(TypeName = "jsonb")] + public Dictionary? Meta { get; set; } + + /// + /// Navigation property for the associated account + /// + [ForeignKey(nameof(AccountId))] + public virtual Account? Account { get; set; } +} diff --git a/DysonNetwork.Common/Models/Auth.cs b/DysonNetwork.Common/Models/Auth.cs index a3a1064..0dda621 100644 --- a/DysonNetwork.Common/Models/Auth.cs +++ b/DysonNetwork.Common/Models/Auth.cs @@ -65,4 +65,26 @@ public class AuthChallenge : ModelBase if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; return this; } +} + +public class AuthTokens +{ + public string AccessToken { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = "Bearer"; + public string? Scope { get; set; } + public string? IdToken { get; set; } + + public static AuthTokens Create(string accessToken, string refreshToken, int expiresIn, string? scope = null, string? idToken = null) + { + return new AuthTokens + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresIn = expiresIn, + Scope = scope, + IdToken = idToken + }; + } } \ No newline at end of file diff --git a/DysonNetwork.Common/Models/Auth/Enums.cs b/DysonNetwork.Common/Models/Auth/Enums.cs new file mode 100644 index 0000000..ae4ca78 --- /dev/null +++ b/DysonNetwork.Common/Models/Auth/Enums.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Common.Models.Auth; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthChallengeType +{ + // Authentication challenges + Password = 0, + EmailCode = 1, + PhoneCode = 2, + Totp = 3, + WebAuthn = 4, + RecoveryCode = 5, + + // Authorization challenges + Consent = 10, + TwoFactor = 11, + + // Account recovery challenges + ResetPassword = 20, + VerifyEmail = 21, + VerifyPhone = 22, + + // Security challenges + Reauthentication = 30, + DeviceVerification = 31, + + // Custom challenges + Custom = 100 +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthChallengePlatform +{ + Web = 0, + Ios = 1, + Android = 2, + Desktop = 3, + Api = 4, + Cli = 5, + Sdk = 6, + + // Special platforms + System = 100, + Unknown = 999 +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthFactorType +{ + Password = 0, + EmailCode = 1, + PhoneCode = 2, + Totp = 3, + WebAuthn = 4, + RecoveryCode = 5, + + // Social and federation + Google = 10, + Apple = 11, + Microsoft = 12, + Facebook = 13, + Twitter = 14, + Github = 15, + + // Enterprise + Saml = 50, + Oidc = 51, + Ldap = 52, + + // Custom factor types + Custom = 100 +} diff --git a/DysonNetwork.Common/Models/LastActiveInfo.cs b/DysonNetwork.Common/Models/LastActiveInfo.cs new file mode 100644 index 0000000..b0ae723 --- /dev/null +++ b/DysonNetwork.Common/Models/LastActiveInfo.cs @@ -0,0 +1,10 @@ +using NodaTime; + +namespace DysonNetwork.Common.Models; + +public class LastActiveInfo +{ + public string SessionId { get; set; } = string.Empty; + public string AccountId { get; set; } = string.Empty; + public Instant SeenAt { get; set; } +} diff --git a/DysonNetwork.Common/Models/OidcUserInfo.cs b/DysonNetwork.Common/Models/OidcUserInfo.cs new file mode 100644 index 0000000..7ac786b --- /dev/null +++ b/DysonNetwork.Common/Models/OidcUserInfo.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; + +namespace DysonNetwork.Common.Models; + +/// +/// Represents user information from an OIDC provider +/// +public class OidcUserInfo +{ + /// + /// The unique identifier for the user from the OIDC provider + /// + public string? UserId { get; set; } + + /// + /// The user's email address + /// + public string? Email { get; set; } + + /// + /// Whether the user's email has been verified by the OIDC provider + /// + public bool EmailVerified { get; set; } + + /// + /// The user's given name (first name) + /// + public string? GivenName { get; set; } + + /// + /// The user's family name (last name) + /// + public string? FamilyName { get; set; } + + /// + /// The user's full name + /// + public string? Name { get; set; } + + /// + /// The user's preferred username + /// + public string? PreferredUsername { get; set; } + + /// + /// URL to the user's profile picture + /// + public string? Picture { get; set; } + + /// + /// The OIDC provider name (e.g., "google", "github") + /// + public string? Provider { get; set; } + + /// + /// OAuth access token from the provider + /// + public string? AccessToken { get; set; } + + /// + /// OAuth refresh token from the provider (if available) + /// + public string? RefreshToken { get; set; } + + /// + /// When the access token expires (if available) + /// + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Additional claims from the ID token or user info endpoint + /// + public Dictionary? Claims { get; set; } + + /// + /// Converts the user info to a metadata dictionary for storage + /// + public Dictionary ToMetadata() + { + var metadata = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(UserId)) + metadata["user_id"] = UserId; + + if (!string.IsNullOrWhiteSpace(Email)) + metadata["email"] = Email; + + metadata["email_verified"] = EmailVerified; + + if (!string.IsNullOrWhiteSpace(GivenName)) + metadata["given_name"] = GivenName; + + if (!string.IsNullOrWhiteSpace(FamilyName)) + metadata["family_name"] = FamilyName; + + if (!string.IsNullOrWhiteSpace(Name)) + metadata["name"] = Name; + + if (!string.IsNullOrWhiteSpace(PreferredUsername)) + metadata["preferred_username"] = PreferredUsername; + + if (!string.IsNullOrWhiteSpace(Picture)) + metadata["picture"] = Picture; + + if (!string.IsNullOrWhiteSpace(Provider)) + metadata["provider"] = Provider; + + if (ExpiresAt.HasValue) + metadata["expires_at"] = ExpiresAt.Value; + + return metadata; + } +} diff --git a/DysonNetwork.Common/Services/FlushBufferService.cs b/DysonNetwork.Common/Services/FlushBufferService.cs new file mode 100644 index 0000000..bde8553 --- /dev/null +++ b/DysonNetwork.Common/Services/FlushBufferService.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DysonNetwork.Common.Services; + +public class FlushBufferService +{ + private readonly Dictionary _buffers = new(); + private readonly object _lockObject = new(); + + private ConcurrentQueue GetOrCreateBuffer() + { + var type = typeof(T); + lock (_lockObject) + { + if (!_buffers.TryGetValue(type, out var buffer)) + { + buffer = new ConcurrentQueue(); + _buffers[type] = buffer; + } + return (ConcurrentQueue)buffer; + } + } + + public void Enqueue(T item) + { + var buffer = GetOrCreateBuffer(); + buffer.Enqueue(item); + } + + public async Task FlushAsync(IFlushHandler handler) + { + var buffer = GetOrCreateBuffer(); + var workingQueue = new List(); + + while (buffer.TryDequeue(out var item)) + { + workingQueue.Add(item); + } + + if (workingQueue.Count == 0) + return; + + try + { + await handler.FlushAsync(workingQueue); + } + catch (Exception) + { + // If flush fails, re-queue the items + foreach (var item in workingQueue) + buffer.Enqueue(item); + throw; + } + } + + public int GetPendingCount() + { + var buffer = GetOrCreateBuffer(); + return buffer.Count; + } +} diff --git a/DysonNetwork.Common/Services/IFlushHandler.cs b/DysonNetwork.Common/Services/IFlushHandler.cs new file mode 100644 index 0000000..d4f6e1e --- /dev/null +++ b/DysonNetwork.Common/Services/IFlushHandler.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DysonNetwork.Common.Services; + +public interface IFlushHandler +{ + Task FlushAsync(IReadOnlyList items); +} diff --git a/DysonNetwork.Drive/DysonNetwork.Drive.csproj b/DysonNetwork.Drive/DysonNetwork.Drive.csproj index 83261f8..ed5019d 100644 --- a/DysonNetwork.Drive/DysonNetwork.Drive.csproj +++ b/DysonNetwork.Drive/DysonNetwork.Drive.csproj @@ -1,9 +1,9 @@ - + - - + + @@ -12,11 +12,13 @@ + + @@ -26,6 +28,8 @@ net9.0 enable enable + true + 1701;1702;1591 diff --git a/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs b/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs index 81f9d68..734c131 100644 --- a/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs +++ b/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs @@ -4,16 +4,11 @@ using Quartz; using DysonNetwork.Drive.Auth; using DysonNetwork.Drive.Models; using Microsoft.Extensions.DependencyInjection; +using DysonNetwork.Common.Models; +using System; namespace DysonNetwork.Drive.Handlers; -public class LastActiveInfo -{ - public Session Session { get; set; } = null!; - public Account Account { get; set; } = null!; - public Instant SeenAt { get; set; } -} - public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler { public async Task FlushAsync(IReadOnlyList items) @@ -23,18 +18,18 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa // Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt var distinctItems = items - .GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id)) + .GroupBy(x => (SessionId: x.SessionId, AccountId: x.AccountId)) .Select(g => g.OrderByDescending(x => x.SeenAt).First()) .ToList(); // Build dictionaries so we can match session/account IDs to their new "last seen" timestamps var sessionIdMap = distinctItems .GroupBy(x => x.SessionId) - .ToDictionary(g => g.Key, g => g.Last().SeenAt); + .ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt); var accountIdMap = distinctItems .GroupBy(x => x.AccountId) - .ToDictionary(g => g.Key, g => g.Last().SeenAt); + .ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt); // Update sessions using native EF Core ExecuteUpdateAsync foreach (var kvp in sessionIdMap) diff --git a/DysonNetwork.Pass/Data/PassDatabase.cs b/DysonNetwork.Pass/Data/PassDatabase.cs index 90bfc09..aa132d2 100644 --- a/DysonNetwork.Pass/Data/PassDatabase.cs +++ b/DysonNetwork.Pass/Data/PassDatabase.cs @@ -1,11 +1,17 @@ using System.Linq.Expressions; using System.Reflection; using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Features.Auth.Models; using DysonNetwork.Sphere.Permission; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using NodaTime; using Quartz; +using Account = DysonNetwork.Pass.Features.Auth.Models.Account; +using AccountConnection = DysonNetwork.Pass.Features.Auth.Models.AccountConnection; +using AccountAuthFactor = DysonNetwork.Pass.Features.Auth.Models.AccountAuthFactor; +using AuthSession = DysonNetwork.Pass.Features.Auth.Models.AuthSession; +using AuthChallenge = DysonNetwork.Pass.Features.Auth.Models.AuthChallenge; namespace DysonNetwork.Pass.Data; @@ -19,11 +25,9 @@ public class PassDatabase( public DbSet PermissionGroupMembers { get; set; } public DbSet MagicSpells { get; set; } - public DbSet Accounts { get; set; } - public DbSet AccountConnections { get; set; } - public DbSet AccountProfiles { get; set; } - public DbSet AccountContacts { get; set; } - public DbSet AccountAuthFactors { get; set; } + public DbSet Accounts { get; set; } = null!; + public DbSet AccountConnections { get; set; } = null!; + public DbSet AccountAuthFactors { get; set; } = null!; public DbSet AccountRelationships { get; set; } public DbSet Notifications { get; set; } public DbSet Badges { get; set; } @@ -77,6 +81,213 @@ public class PassDatabase( .WithMany(a => a.IncomingRelationships) .HasForeignKey(r => r.RelatedId); + // Configure AuthSession + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("gen_random_uuid()"); + + entity.Property(e => e.Label) + .HasMaxLength(500); + + entity.Property(e => e.LastGrantedAt) + .IsRequired(); + + entity.Property(e => e.ExpiredAt) + .IsRequired(); + + entity.Property(e => e.AccessToken) + .HasMaxLength(1000); + + entity.Property(e => e.RefreshToken) + .HasMaxLength(1000); + + entity.Property(e => e.IpAddress) + .HasMaxLength(128); + + entity.Property(e => e.UserAgent) + .HasMaxLength(500); + + entity.HasOne(s => s.Account) + .WithMany(a => a.Sessions) + .HasForeignKey(s => s.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(s => s.Challenge) + .WithMany() + .HasForeignKey(s => s.ChallengeId) + .OnDelete(DeleteBehavior.SetNull); + + entity.Property(e => e.Metadata) + .HasColumnType("jsonb"); + }); + + // Configure AuthChallenge + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Type) + .IsRequired() + .HasConversion(); + + entity.Property(e => e.Platform) + .IsRequired() + .HasConversion(); + + entity.Property(e => e.CreatedAt) + .IsRequired(); + + entity.Property(e => e.ExpiredAt); + + entity.Property(e => e.StepRemain) + .IsRequired() + .HasDefaultValue(1); + + entity.Property(e => e.StepTotal) + .IsRequired() + .HasDefaultValue(1); + + entity.Property(e => e.FailedAttempts) + .IsRequired() + .HasDefaultValue(0); + + entity.Property(e => e.IpAddress) + .HasMaxLength(128); + + entity.Property(e => e.UserAgent) + .HasMaxLength(512); + + entity.Property(e => e.DeviceId) + .HasMaxLength(256); + + entity.Property(e => e.Nonce) + .HasMaxLength(1024); + + entity.Property(e => e.BlacklistFactors) + .HasColumnType("jsonb"); + + entity.Property(e => e.Audiences) + .HasColumnType("jsonb"); + + entity.Property(e => e.Scopes) + .HasColumnType("jsonb"); + + entity.HasOne() + .WithMany(a => a.Challenges) + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + entity.Ignore(e => e.Location); // Ignore Point type as it's not directly supported by EF Core + }); + + // Configure AccountAuthFactor + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("gen_random_uuid()"); + + entity.Property(e => e.FactorType) + .IsRequired() + .HasConversion(); + + entity.Property(e => e.Name) + .IsRequired() + .HasMaxLength(100); + + entity.Property(e => e.Description) + .HasMaxLength(500); + + entity.Property(e => e.Secret) + .IsRequired() + .HasMaxLength(1024); + + entity.Property(e => e.IsDefault) + .IsRequired() + .HasDefaultValue(false); + + entity.Property(e => e.IsBackup) + .IsRequired() + .HasDefaultValue(false); + + entity.Property(e => e.LastUsedAt); + entity.Property(e => e.EnabledAt); + entity.Property(e => e.DisabledAt); + + entity.Property(e => e.Metadata) + .HasColumnType("jsonb"); + + entity.HasOne(f => f.Account) + .WithMany(a => a.AuthFactors) + .HasForeignKey(f => f.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + // Remove the incorrect relationship configuration + // The relationship is already defined in the AuthSession configuration + }); + + // Configure Account + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Email) + .IsRequired() + .HasMaxLength(256); + + entity.Property(e => e.Name) + .IsRequired() + .HasMaxLength(256); + + entity.Property(e => e.Status) + .HasMaxLength(32); + + entity.Property(e => e.CreatedAt) + .IsRequired(); + + entity.Property(e => e.UpdatedAt) + .IsRequired(); + }); + + // Configure AccountConnection + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Provider) + .IsRequired() + .HasMaxLength(50); + + entity.Property(e => e.ProviderId) + .IsRequired() + .HasMaxLength(256); + + entity.Property(e => e.DisplayName) + .HasMaxLength(256); + + entity.Property(e => e.AccessToken) + .HasMaxLength(1000); + + entity.Property(e => e.RefreshToken) + .HasMaxLength(1000); + + entity.Property(e => e.ExpiresAt); + + entity.Property(e => e.ProfileData) + .HasColumnType("jsonb"); + + entity.HasOne() + .WithMany(a => a.Connections) + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + // Automatically apply soft-delete filter to all entities inheriting BaseModel foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 7f57ec8..7093098 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -44,7 +44,6 @@ - diff --git a/DysonNetwork.Pass/Features/Auth/Interfaces/IAuthenticationService.cs b/DysonNetwork.Pass/Features/Auth/Interfaces/IAuthenticationService.cs new file mode 100644 index 0000000..4c04e51 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Interfaces/IAuthenticationService.cs @@ -0,0 +1,16 @@ +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Features.Auth.Models; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Interfaces; + +public interface IAuthenticationService +{ + Task AuthenticateAsync(string username, string password); + Task AuthenticateWithOidcAsync(string provider, string code, string state); + Task RefreshTokenAsync(string refreshToken); + Task ValidateTokenAsync(string token); + Task LogoutAsync(Guid sessionId); + Task ValidateSessionAsync(Guid sessionId); + Task GetSessionAsync(Guid sessionId); +} diff --git a/DysonNetwork.Pass/Features/Auth/Interfaces/IOidcService.cs b/DysonNetwork.Pass/Features/Auth/Interfaces/IOidcService.cs new file mode 100644 index 0000000..7255be7 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Interfaces/IOidcService.cs @@ -0,0 +1,12 @@ +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Features.Auth.Models; + +namespace DysonNetwork.Pass.Features.Auth.Interfaces; + +public interface IOidcService +{ + string GetAuthorizationUrl(string state, string nonce); + Task ProcessCallbackAsync(OidcCallbackData callbackData); + Task AuthenticateAsync(string provider, string code, string state); + IEnumerable GetSupportedProviders(); +} diff --git a/DysonNetwork.Pass/Features/Auth/Interfaces/ISessionService.cs b/DysonNetwork.Pass/Features/Auth/Interfaces/ISessionService.cs new file mode 100644 index 0000000..cf2231a --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Interfaces/ISessionService.cs @@ -0,0 +1,14 @@ +using DysonNetwork.Common.Models; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Interfaces; + +public interface ISessionService +{ + Task CreateSessionAsync(Guid accountId, string ipAddress, string userAgent); + Task GetSessionAsync(Guid sessionId); + Task ValidateSessionAsync(Guid sessionId); + Task InvalidateSessionAsync(Guid sessionId); + Task InvalidateAllSessionsAsync(Guid accountId, Guid? excludeSessionId = null); + Task UpdateSessionActivityAsync(Guid sessionId); +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/Account.cs b/DysonNetwork.Pass/Features/Auth/Models/Account.cs new file mode 100644 index 0000000..d4edf23 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/Account.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Common.Models; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +[Index(nameof(Email), IsUnique = true)] +public class Account : ModelBase +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } + + [Required] + [MaxLength(256)] + public string Email { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string Name { get; set; } = string.Empty; + + [MaxLength(32)] + public string? Status { get; set; } + + [Required] + public Instant CreatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant(); + + [Required] + public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant(); + + // Navigation properties + [JsonIgnore] + public virtual ICollection Sessions { get; set; } = new List(); + + [JsonIgnore] + public virtual ICollection Challenges { get; set; } = new List(); + + [JsonIgnore] + public virtual ICollection AuthFactors { get; set; } = new List(); + + [JsonIgnore] + public virtual ICollection Connections { get; set; } = new List(); + + public void UpdateTimestamp() + { + UpdatedAt = SystemClock.Instance.GetCurrentInstant(); + } + + public static Account FromCommonModel(DysonNetwork.Common.Models.Account commonAccount) + { + return new Account + { + Id = Guid.Parse(commonAccount.Id), + Email = commonAccount.Profile?.Email ?? string.Empty, + Name = commonAccount.Name, + Status = commonAccount.Status, + CreatedAt = commonAccount.CreatedAt, + UpdatedAt = commonAccount.UpdatedAt + }; + } + + public DysonNetwork.Common.Models.Account ToCommonModel() + { + return new DysonNetwork.Common.Models.Account + { + Id = Id.ToString(), + Name = Name, + Status = Status, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + Profile = new DysonNetwork.Common.Models.Profile + { + Email = Email, + DisplayName = Name + } + }; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AccountAuthFactor.cs b/DysonNetwork.Pass/Features/Auth/Models/AccountAuthFactor.cs new file mode 100644 index 0000000..9fce410 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AccountAuthFactor.cs @@ -0,0 +1,74 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using DysonNetwork.Common.Models; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AccountAuthFactor : ModelBase +{ + [Required] + public Guid AccountId { get; set; } + + [ForeignKey(nameof(AccountId))] + public virtual Account Account { get; set; } = null!; + + [Required] + public AuthFactorType FactorType { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + [Required] + public string Secret { get; set; } = string.Empty; + + [Required] + public bool IsDefault { get; set; } + + [Required] + public bool IsBackup { get; set; } + + public Instant? LastUsedAt { get; set; } + + public Instant? EnabledAt { get; set; } + + public Instant? DisabledAt { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? Metadata { get; set; } + + // Navigation property for related AuthSessions + public virtual ICollection Sessions { get; set; } = new List(); + + public void UpdateMetadata(Action updateAction) + { + if (Metadata == null) + { + Metadata = JsonSerializer.SerializeToDocument(new { }); + } + + updateAction(Metadata); + } + + public void MarkAsUsed() + { + LastUsedAt = SystemClock.Instance.GetCurrentInstant(); + } + + public void Enable() + { + EnabledAt = SystemClock.Instance.GetCurrentInstant(); + DisabledAt = null; + } + + public void Disable() + { + DisabledAt = SystemClock.Instance.GetCurrentInstant(); + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AccountConnection.cs b/DysonNetwork.Pass/Features/Auth/Models/AccountConnection.cs new file mode 100644 index 0000000..1e1f2a5 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AccountConnection.cs @@ -0,0 +1,70 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.Json.Serialization; +using DysonNetwork.Pass.Models; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AccountConnection : ModelBase +{ + [Required] + public Guid AccountId { get; set; } + + [ForeignKey(nameof(AccountId))] + [JsonIgnore] + public virtual Account Account { get; set; } = null!; + + [Required] + [MaxLength(50)] + public string Provider { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string ProviderId { get; set; } = string.Empty; + + [MaxLength(256)] + public string? DisplayName { get; set; } + + [MaxLength(1000)] + public string? AccessToken { get; set; } + + [MaxLength(1000)] + public string? RefreshToken { get; set; } + + public Instant? ExpiresAt { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? ProfileData { get; set; } + + public Instant ConnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant(); + public Instant? LastUsedAt { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? Metadata { get; set; } + + public bool IsConnected => ExpiresAt == null || ExpiresAt > SystemClock.Instance.GetCurrentInstant(); + + public void UpdateTokens(string? accessToken, string? refreshToken, Instant? expiresAt) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ExpiresAt = expiresAt; + LastUsedAt = SystemClock.Instance.GetCurrentInstant(); + } + + public void Disconnect() + { + AccessToken = null; + RefreshToken = null; + ExpiresAt = null; + ConnectedAt = default; // Set to default value for Instant + } + + public void UpdateProfileData(JsonDocument? profileData) + { + ProfileData = profileData; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AuthChallenge.cs b/DysonNetwork.Pass/Features/Auth/Models/AuthChallenge.cs new file mode 100644 index 0000000..74c2cf9 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AuthChallenge.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.Json.Serialization; +using DysonNetwork.Common.Models.Auth; +using DysonNetwork.Pass.Models; +using NetTopologySuite.Geometries; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AuthChallenge : ModelBase +{ + [Required] + public Guid AccountId { get; set; } + + [ForeignKey(nameof(AccountId))] + [JsonIgnore] + public virtual Account Account { get; set; } = null!; + + [Required] + [Column(TypeName = "varchar(50)")] + public AuthChallengeType Type { get; set; } + + [Required] + [Column(TypeName = "varchar(50)")] + public AuthChallengePlatform Platform { get; set; } + + public Instant? ExpiredAt { get; set; } + + [Required] + public int StepRemain { get; set; } = 1; + + [Required] + public int StepTotal { get; set; } = 1; + + [Required] + public int FailedAttempts { get; set; } = 0; + + [MaxLength(128)] + public string? IpAddress { get; set; } + + [MaxLength(512)] + public string? UserAgent { get; set; } + + [MaxLength(256)] + public string? DeviceId { get; set; } + + [MaxLength(1024)] + public string? Nonce { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? BlacklistFactors { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? Audiences { get; set; } + + [Column(TypeName = "jsonb")] + public JsonDocument? Scopes { get; set; } + + [NotMapped] + public Point? Location { get; set; } + + // Navigation property for AuthSession + [JsonIgnore] + public virtual ICollection Sessions { get; set; } = new List(); + + public bool IsExpired() => ExpiredAt != null && SystemClock.Instance.GetCurrentInstant() >= ExpiredAt.Value; + + public bool CanAttempt(int maxAttempts = 5) => !IsExpired() && FailedAttempts < maxAttempts; + + public void RecordAttempt() + { + if (IsExpired()) + return; + + FailedAttempts++; + } + + public void UpdateStep(int step, int totalSteps) + { + StepRemain = step; + StepTotal = totalSteps; + } + + public void UpdateExpiration(Instant? expiresAt) + { + ExpiredAt = expiresAt; + } + + public void UpdateBlacklistFactors(IEnumerable factors) + { + BlacklistFactors = JsonSerializer.SerializeToDocument(factors); + } + + public void UpdateAudiences(IEnumerable audiences) + { + Audiences = JsonSerializer.SerializeToDocument(audiences); + } + + public void UpdateScopes(IEnumerable scopes) + { + Scopes = JsonSerializer.SerializeToDocument(scopes); + } + + public void UpdateLocation(double? latitude, double? longitude) + { + if (latitude.HasValue && longitude.HasValue) + { + Location = new Point(longitude.Value, latitude.Value) { SRID = 4326 }; + } + } + + public void UpdateDeviceInfo(string? ipAddress, string? userAgent, string? deviceId = null) + { + IpAddress = ipAddress; + UserAgent = userAgent; + DeviceId = deviceId; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AuthFactorType.cs b/DysonNetwork.Pass/Features/Auth/Models/AuthFactorType.cs new file mode 100644 index 0000000..15bac15 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AuthFactorType.cs @@ -0,0 +1,13 @@ +namespace DysonNetwork.Pass.Features.Auth.Models; + +public enum AuthFactorType +{ + Password = 0, + TOTP = 1, + Email = 2, + Phone = 3, + SecurityKey = 4, + RecoveryCode = 5, + BackupCode = 6, + OIDC = 7 +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AuthResult.cs b/DysonNetwork.Pass/Features/Auth/Models/AuthResult.cs new file mode 100644 index 0000000..5357d8e --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AuthResult.cs @@ -0,0 +1,13 @@ +using DysonNetwork.Common.Models; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AuthResult +{ + public bool Success { get; set; } + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public AuthSession? Session { get; set; } + public string? Error { get; set; } + public IEnumerable? RequiredFactors { get; set; } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AuthSession.cs b/DysonNetwork.Pass/Features/Auth/Models/AuthSession.cs new file mode 100644 index 0000000..8c5f2ce --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AuthSession.cs @@ -0,0 +1,87 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using DysonNetwork.Common.Models; +using NodaTime; +using Account = DysonNetwork.Common.Models.Account; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AuthSession : ModelBase +{ + [Required] + public Guid AccountId { get; set; } + + [ForeignKey(nameof(AccountId))] + public virtual Account Account { get; set; } = null!; + + [Required] + [MaxLength(500)] + public string Label { get; set; } = string.Empty; + + [Required] + public Instant LastGrantedAt { get; set; } = SystemClock.Instance.GetCurrentInstant(); + + [Required] + public Instant ExpiredAt { get; set; } + + [MaxLength(1000)] + public string? AccessToken { get; set; } + + [MaxLength(1000)] + public string? RefreshToken { get; set; } + + public bool IsRevoked { get; set; } + + public string? IpAddress { get; set; } + + [MaxLength(500)] + public string? UserAgent { get; set; } + + [Column(TypeName = "jsonb")] + public Dictionary? Metadata { get; set; } + + public Guid? ChallengeId { get; set; } + + [ForeignKey(nameof(ChallengeId))] + public virtual AuthChallenge? Challenge { get; set; } + + // Helper methods + public bool IsExpired() => SystemClock.Instance.GetCurrentInstant() >= ExpiredAt; + + public bool IsActive() => !IsExpired() && !IsRevoked; + + public void UpdateLastActivity() + { + LastGrantedAt = SystemClock.Instance.GetCurrentInstant(); + } + + public void SetChallenge(AuthChallenge challenge) + { + Challenge = challenge; + ChallengeId = challenge.Id; + } + + public void ClearChallenge() + { + Challenge = null; + ChallengeId = null; + } + + public void UpdateTokens(string accessToken, string refreshToken, Duration accessTokenLifetime) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(accessTokenLifetime); + UpdateLastActivity(); + } + + public void Revoke() + { + IsRevoked = true; + AccessToken = null; + RefreshToken = null; + ExpiredAt = SystemClock.Instance.GetCurrentInstant(); + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/AuthTokens.cs b/DysonNetwork.Pass/Features/Auth/Models/AuthTokens.cs new file mode 100644 index 0000000..f2eb4af --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/AuthTokens.cs @@ -0,0 +1,9 @@ +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class AuthTokens +{ + public string AccessToken { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public int ExpiresIn { get; set; } + public string TokenType { get; set; } = "Bearer"; +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/OidcCallbackData.cs b/DysonNetwork.Pass/Features/Auth/Models/OidcCallbackData.cs new file mode 100644 index 0000000..6efd2ee --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/OidcCallbackData.cs @@ -0,0 +1,9 @@ +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class OidcCallbackData +{ + public string Code { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string? Error { get; set; } + public string? ErrorDescription { get; set; } +} diff --git a/DysonNetwork.Pass/Features/Auth/Models/OidcUserInfo.cs b/DysonNetwork.Pass/Features/Auth/Models/OidcUserInfo.cs new file mode 100644 index 0000000..bdd0d71 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Models/OidcUserInfo.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.Features.Auth.Models; + +public class OidcUserInfo +{ + [JsonPropertyName("sub")] + public string Subject { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("given_name")] + public string? GivenName { get; set; } + + [JsonPropertyName("family_name")] + public string? FamilyName { get; set; } + + [JsonPropertyName("middle_name")] + public string? MiddleName { get; set; } + + [JsonPropertyName("nickname")] + public string? Nickname { get; set; } + + [JsonPropertyName("preferred_username")] + public string? PreferredUsername { get; set; } + + [JsonPropertyName("profile")] + public string? Profile { get; set; } + + [JsonPropertyName("picture")] + public string? Picture { get; set; } + + [JsonPropertyName("website")] + public string? Website { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("email_verified")] + public bool? EmailVerified { get; set; } + + [JsonPropertyName("gender")] + public string? Gender { get; set; } + + [JsonPropertyName("birthdate")] + public string? Birthdate { get; set; } + + [JsonPropertyName("zoneinfo")] + public string? ZoneInfo { get; set; } + + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("phone_number_verified")] + public bool? PhoneNumberVerified { get; set; } + + [JsonPropertyName("address")] + public Dictionary? Address { get; set; } + + [JsonPropertyName("updated_at")] + public long? UpdatedAt { get; set; } + + // Custom claims + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/DysonNetwork.Pass/Features/Auth/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Features/Auth/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0b5b8dd --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using DysonNetwork.Pass.Features.Auth.Interfaces; +using DysonNetwork.Pass.Features.Auth.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DysonNetwork.Pass.Features.Auth; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAuthServices(this IServiceCollection services) + { + // Core services + services.AddScoped(); + services.AddScoped(); + + // OIDC services will be registered by their respective implementations + services.AddScoped(); + + // Add HTTP context accessor if not already added + services.AddHttpContextAccessor(); + + return services; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/AccountConnectionService.cs b/DysonNetwork.Pass/Features/Auth/Services/AccountConnectionService.cs new file mode 100644 index 0000000..ca7dd0a --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/AccountConnectionService.cs @@ -0,0 +1,158 @@ +using System; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Pass.Features.Auth.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +// Use fully qualified names to avoid ambiguity +using CommonAccount = DysonNetwork.Common.Models.Account; +using CommonAccountConnection = DysonNetwork.Common.Models.AccountConnection; +using CommonOidcUserInfo = DysonNetwork.Common.Models.OidcUserInfo; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public class AccountConnectionService : IAccountConnectionService +{ + private readonly PassDatabase _db; + private readonly IClock _clock; + private readonly ISessionService _sessionService; + + public AccountConnectionService(PassDatabase db, IClock clock, ISessionService sessionService) + { + _db = db; + _clock = clock; + _sessionService = sessionService; + } + + public async Task FindOrCreateConnection(CommonOidcUserInfo userInfo, string provider) + { + if (string.IsNullOrEmpty(userInfo.UserId)) + throw new ArgumentException("User ID is required", nameof(userInfo)); + + // Try to find existing connection + var connection = await _db.AccountConnections + .FirstOrDefaultAsync(c => c.Provider == provider && + c.ProvidedIdentifier == userInfo.UserId); + + if (connection == null) + { + // Create new connection + connection = new CommonAccountConnection + { + Id = Guid.NewGuid().ToString("N"), + Provider = provider, + ProvidedIdentifier = userInfo.UserId, + DisplayName = userInfo.Name, + CreatedAt = _clock.GetCurrentInstant(), + LastUsedAt = _clock.GetCurrentInstant(), + Meta = userInfo.ToMetadata() + }; + + await _db.AccountConnections.AddAsync(connection); + } + + // Update connection with latest info + await UpdateConnection(connection, userInfo); + await _db.SaveChangesAsync(); + + return connection; + } + + public async Task UpdateConnection(CommonAccountConnection connection, CommonOidcUserInfo userInfo) + { + connection.LastUsedAt = _clock.GetCurrentInstant(); + connection.AccessToken = userInfo.AccessToken; + connection.RefreshToken = userInfo.RefreshToken; + connection.ExpiresAt = userInfo.ExpiresAt != null ? Instant.FromDateTimeOffset(userInfo.ExpiresAt.Value) : null; + + // Update metadata + var metadata = userInfo.ToMetadata(); + if (metadata != null) + { + connection.Meta = metadata; + } + + _db.AccountConnections.Update(connection); + await _db.SaveChangesAsync(); + } + + public async Task FindConnection(string provider, string userId) + { + if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(userId)) + return null; + + return await _db.AccountConnections + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Provider == provider && + c.ProvidedIdentifier == userId); + } + + public async Task CreateSessionAsync(CommonAccount account, string? deviceId = null) + { + if (account == null) + throw new ArgumentNullException(nameof(account)); + + var now = _clock.GetCurrentInstant(); + var session = new Models.AuthSession + { + Id = Guid.NewGuid(), + AccountId = Guid.Parse(account.Id), + Label = $"OIDC Session {DateTime.UtcNow:yyyy-MM-dd}", + LastGrantedAt = now, + ExpiredAt = now.Plus(Duration.FromDays(30)), // 30-day session + // Challenge will be set later if needed + }; + + await _db.AuthSessions.AddAsync(session); + await _db.SaveChangesAsync(); + + return session; + } + + public async Task AddConnectionAsync(CommonAccount account, CommonOidcUserInfo userInfo, string provider) + { + if (account == null) + throw new ArgumentNullException(nameof(account)); + if (string.IsNullOrEmpty(userInfo.UserId)) + throw new ArgumentException("User ID is required", nameof(userInfo)); + + // Check if connection already exists + var existingConnection = await FindConnection(provider, userInfo.UserId); + if (existingConnection != null) + { + // Update existing connection + await UpdateConnection(existingConnection, userInfo); + return existingConnection; + } + + // Create new connection + var connection = new CommonAccountConnection + { + Id = Guid.NewGuid().ToString("N"), + AccountId = account.Id, + Provider = provider, + ProvidedIdentifier = userInfo.UserId, + DisplayName = userInfo.Name, + CreatedAt = _clock.GetCurrentInstant(), + LastUsedAt = _clock.GetCurrentInstant(), + Meta = userInfo.ToMetadata() + }; + + // Set token info if available + if (userInfo.AccessToken != null) + { + connection.AccessToken = userInfo.AccessToken; + connection.RefreshToken = userInfo.RefreshToken; + connection.ExpiresAt = userInfo.ExpiresAt != null + ? Instant.FromDateTimeOffset(userInfo.ExpiresAt.Value) + : null; + } + + await _db.AccountConnections.AddAsync(connection); + await _db.SaveChangesAsync(); + + return connection; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/AccountService.cs b/DysonNetwork.Pass/Features/Auth/Services/AccountService.cs new file mode 100644 index 0000000..56f50c4 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/AccountService.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Pass.Features.Auth.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Account = DysonNetwork.Common.Models.Account; +using AuthTokens = DysonNetwork.Common.Models.AuthTokens; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public class AccountService : IAccountService +{ + private readonly PassDatabase _db; + private readonly IClock _clock; + + public AccountService(PassDatabase db, IClock clock) + { + _db = db; + _clock = clock; + } + + public async Task CreateAccount(Common.Models.OidcUserInfo userInfo) + { + if (string.IsNullOrEmpty(userInfo.Email)) + throw new ArgumentException("Email is required for account creation", nameof(userInfo)); + + var now = _clock.GetCurrentInstant(); + var account = new Models.Account + { + Id = Guid.NewGuid(), + Email = userInfo.Email, + Name = userInfo.Name ?? userInfo.Email.Split('@')[0], + CreatedAt = now, + UpdatedAt = now, + Status = "Active" + }; + + _db.Accounts.Add(account); + await _db.SaveChangesAsync(); + + return new Account + { + Id = account.Id.ToString(), + Email = account.Email, + Name = account.Name, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt, + Status = account.Status + }; + } + + public async Task FindByEmailAsync(string email) + { + if (string.IsNullOrEmpty(email)) + return null; + + var account = await _db.Accounts + .FirstOrDefaultAsync(a => a.Email == email); + + if (account == null) + return null; + + return new Account + { + Id = account.Id.ToString(), + Email = account.Email, + Name = account.Name, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt, + Status = account.Status + }; + } + + public async Task FindByIdAsync(string accountId) + { + if (string.IsNullOrEmpty(accountId) || !Guid.TryParse(accountId, out var id)) + return null; + + var account = await _db.Accounts + .FirstOrDefaultAsync(a => a.Id == id); + + if (account == null) + return null; + + return new Account + { + Id = account.Id.ToString(), + Email = account.Email, + Name = account.Name, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt, + Status = account.Status + }; + } + + public async Task UpdateAccount(Account account) + { + if (!Guid.TryParse(account.Id, out var id)) + throw new ArgumentException("Invalid account ID format", nameof(account)); + + var existingAccount = await _db.Accounts.FindAsync(id); + if (existingAccount == null) + throw new InvalidOperationException($"Account with ID {account.Id} not found"); + + existingAccount.Name = account.Name; + existingAccount.Email = account.Email; + existingAccount.UpdatedAt = _clock.GetCurrentInstant(); + existingAccount.Status = account.Status; + + _db.Accounts.Update(existingAccount); + await _db.SaveChangesAsync(); + } + + public async Task FindOrCreateAccountAsync(Common.Models.OidcUserInfo userInfo, string provider) + { + if (string.IsNullOrEmpty(userInfo.Email)) + throw new ArgumentException("Email is required for account creation", nameof(userInfo)); + + // Check if account exists by email + var account = await FindByEmailAsync(userInfo.Email); + if (account != null) + return account; + + // Create new account if not found + return await CreateAccount(userInfo); + } + + public async Task GetAccountByIdAsync(Guid accountId) + { + var account = await _db.Accounts + .FirstOrDefaultAsync(a => a.Id == accountId); + + if (account == null) + return null; + + return new Account + { + Id = account.Id.ToString(), + Email = account.Email, + Name = account.Name, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt, + Status = account.Status + }; + } + + public async Task GenerateAuthTokensAsync(Account account, string sessionId) + { + if (!Guid.TryParse(sessionId, out var sessionGuid)) + throw new ArgumentException("Invalid session ID format", nameof(sessionId)); + + var now = _clock.GetCurrentInstant(); + var accessTokenLifetime = Duration.FromHours(1); + var accessTokenExpiry = now.Plus(accessTokenLifetime); + + // In a real implementation, you would generate proper JWT tokens here + // This is a simplified version for demonstration + var accessToken = $"access_token_{Guid.NewGuid()}"; + var refreshToken = $"refresh_token_{Guid.NewGuid()}"; + + // Create or update the session + var session = await _db.AuthSessions.FindAsync(sessionGuid); + if (session != null) + { + session.UpdateTokens(accessToken, refreshToken, accessTokenLifetime); + _db.AuthSessions.Update(session); + await _db.SaveChangesAsync(); + } + + return new AuthTokens + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresIn = (int)accessTokenLifetime.TotalSeconds, + TokenType = "Bearer" + }; + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/Auth.cs b/DysonNetwork.Pass/Features/Auth/Services/Auth.cs index f1cb516..e85bd63 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/Auth.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/Auth.cs @@ -4,13 +4,13 @@ using System.Text.Encodings.Web; using DysonNetwork.Pass.Features.Account; using DysonNetwork.Pass.Features.Auth.OidcProvider.Services; using DysonNetwork.Common.Services; -using DysonNetwork.Drive.Handlers; +using DysonNetwork.Common.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using NodaTime; using DysonNetwork.Pass.Data; using DysonNetwork.Common.Models; -using DysonNetwork.Drive; + namespace DysonNetwork.Pass.Features.Auth.Services; @@ -125,10 +125,10 @@ public class DysonTokenAuthHandler( var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName); - var lastInfo = new LastActiveInfo + var lastInfo = new DysonNetwork.Common.Models.LastActiveInfo { - Account = session.Account, - Session = session, + AccountId = session.Account.Id.ToString(), + SessionId = session.Id.ToString(), SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(), }; fbs.Enqueue(lastInfo); diff --git a/DysonNetwork.Pass/Features/Auth/Services/AuthenticationService.cs b/DysonNetwork.Pass/Features/Auth/Services/AuthenticationService.cs new file mode 100644 index 0000000..233ead3 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/AuthenticationService.cs @@ -0,0 +1,195 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Pass.Features.Auth.Interfaces; +using DysonNetwork.Pass.Features.Auth.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public class AuthenticationService : IAuthenticationService +{ + private readonly PassDatabase _db; + private readonly IConfiguration _configuration; + private readonly ISessionService _sessionService; + private readonly IOidcService _oidcService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AuthenticationService( + PassDatabase db, + IConfiguration configuration, + ISessionService sessionService, + IOidcService oidcService, + IHttpContextAccessor httpContextAccessor) + { + _db = db; + _configuration = configuration; + _sessionService = sessionService; + _oidcService = oidcService; + _httpContextAccessor = httpContextAccessor; + } + + public async Task AuthenticateAsync(string username, string password) + { + // First try to find by username (Name in the Account model) + var account = await _db.Accounts + .Include(a => a.Profile) // Include Profile for email lookup + .FirstOrDefaultAsync(a => a.Name == username); + + // If not found by username, try to find by email in the Profile + if (account == null) + { + account = await _db.Accounts + .Include(a => a.Profile) + .FirstOrDefaultAsync(a => a.Profile != null && a.Profile.Email == username); + } + + if (account == null || !await VerifyPasswordAsync(account, password)) + { + return new AuthResult { Success = false, Error = "Invalid username/email or password" }; + } + + return await CreateAuthResult(account); + } + + private async Task VerifyPasswordAsync(Account account, string password) + { + // Find password auth factor for the account + var passwordFactor = await _db.AccountAuthFactors + .FirstOrDefaultAsync(f => f.AccountId == account.Id && f.FactorType == AccountAuthFactorType.Password); + + if (passwordFactor == null) + return false; + + return BCrypt.Net.BCrypt.Verify(password, passwordFactor.Secret); + } + + public async Task AuthenticateWithOidcAsync(string provider, string code, string state) + { + return await _oidcService.AuthenticateAsync(provider, code, state); + } + + public async Task RefreshTokenAsync(string refreshToken) + { + var session = await _db.AuthSessions + .FirstOrDefaultAsync(s => s.RefreshToken == refreshToken && !s.IsRevoked); + + if (session == null || session.RefreshTokenExpiryTime <= SystemClock.Instance.GetCurrentInstant()) + { + return new AuthResult { Success = false, Error = "Invalid or expired refresh token" }; + } + + var account = await _db.Accounts.FindAsync(session.AccountId); + if (account == null) + { + return new AuthResult { Success = false, Error = "Account not found" }; + } + + // Invalidate the old session + await _sessionService.InvalidateSessionAsync(session.Id); + + // Create a new session + return await CreateAuthResult(account); + } + + public async Task ValidateTokenAsync(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!); + + try + { + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidateAudience = false, + ClockSkew = TimeSpan.Zero, + ValidIssuer = _configuration["Jwt:Issuer"] + }, out _); + + return true; + } + catch + { + return false; + } + } + + public async Task LogoutAsync(Guid sessionId) + { + await _sessionService.InvalidateSessionAsync(sessionId); + } + + public async Task ValidateSessionAsync(Guid sessionId) + { + return await _sessionService.ValidateSessionAsync(sessionId); + } + + public async Task GetSessionAsync(Guid sessionId) + { + var session = await _sessionService.GetSessionAsync(sessionId); + if (session == null) + throw new Exception("Session not found"); + + return session; + } + + private async Task CreateAuthResult(Account account) + { + var ipAddress = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString() ?? string.Empty; + var userAgent = _httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty; + + var session = await _sessionService.CreateSessionAsync(account.Id, ipAddress, userAgent); + var token = GenerateJwtToken(account, session.Id); + + return new AuthResult + { + Success = true, + AccessToken = token, + RefreshToken = session.RefreshToken, + Session = session + }; + } + + private string GenerateJwtToken(Account account, Guid sessionId) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, account.Id.ToString()), + new Claim(ClaimTypes.Name, account.Username), + new Claim("session_id", sessionId.ToString()) + }), + Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature + ), + Issuer = _configuration["Jwt:Issuer"], + Audience = _configuration["Jwt:Audience"] + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + private bool VerifyPasswordHash(string password, byte[] storedHash, byte[] storedSalt) + { + using var hmac = new HMACSHA512(storedSalt); + var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(password)); + return computedHash.SequenceEqual(storedHash); + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/IAccountConnectionService.cs b/DysonNetwork.Pass/Features/Auth/Services/IAccountConnectionService.cs new file mode 100644 index 0000000..2f62522 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/IAccountConnectionService.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Features.Auth.Models; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public interface IAccountConnectionService +{ + /// + /// Finds an existing account connection or creates a new one + /// + Task FindOrCreateConnection(Common.Models.OidcUserInfo userInfo, string provider); + + /// + /// Updates an existing connection with new token information + /// + Task UpdateConnection(Common.Models.AccountConnection connection, Common.Models.OidcUserInfo userInfo); + + /// + /// Finds an account connection by provider and user ID + /// + Task FindConnection(string provider, string userId); + + /// + /// Creates a new session for the specified account + /// + Task CreateSessionAsync(Common.Models.Account account, string? deviceId = null); + + /// + /// Adds a new OIDC connection to an account + /// + Task AddConnectionAsync(Common.Models.Account account, Common.Models.OidcUserInfo userInfo, string provider); +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/IAccountService.cs b/DysonNetwork.Pass/Features/Auth/Services/IAccountService.cs new file mode 100644 index 0000000..2f98e8b --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/IAccountService.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Features.Auth.Models; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public interface IAccountService +{ + /// + /// Creates a new account from OIDC user info + /// + Task CreateAccount(Common.Models.OidcUserInfo userInfo); + + /// + /// Finds an account by email + /// + Task FindByEmailAsync(string email); + + /// + /// Finds an account by ID + /// + Task FindByIdAsync(string accountId); + + /// + /// Updates an existing account + /// + Task UpdateAccount(Common.Models.Account account); + + /// + /// Finds or creates an account based on OIDC user info + /// + Task FindOrCreateAccountAsync(Common.Models.OidcUserInfo userInfo, string provider); + + /// + /// Gets an account by ID + /// + Task GetAccountByIdAsync(Guid accountId); + + /// + /// Generates authentication tokens for an account + /// + Task GenerateAuthTokensAsync(Common.Models.Account account, string sessionId); +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/OidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OidcService.cs new file mode 100644 index 0000000..f554403 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/OidcService.cs @@ -0,0 +1,144 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Json; +using System.Text.Json; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Pass.Features.Auth.Interfaces; +using DysonNetwork.Pass.Features.Auth.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public class OidcService : IOidcService +{ + protected readonly IConfiguration _configuration; + protected readonly IHttpClientFactory _httpClientFactory; + protected readonly PassDatabase _db; + protected readonly IAuthenticationService _authService; + protected readonly ILogger _logger; + + public OidcService( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + PassDatabase db, + IAuthenticationService authService, + ILogger logger) + { + _configuration = configuration; + _httpClientFactory = httpClientFactory; + _db = db; + _authService = authService; + _logger = logger; + } + + public virtual string GetAuthorizationUrl(string state, string nonce) + { + throw new NotImplementedException("This method should be implemented by derived classes"); + } + + public virtual async Task ProcessCallbackAsync(OidcCallbackData callbackData) + { + throw new NotImplementedException("This method should be implemented by derived classes"); + } + + public virtual async Task AuthenticateAsync(string provider, string code, string state) + { + try + { + var userInfo = await ProcessCallbackAsync(new OidcCallbackData + { + Code = code, + State = state + }); + + // Find or create user based on the OIDC subject and provider + var account = await FindOrCreateUser(userInfo, provider); + + // Create authentication result + return await _authService.AuthenticateWithOidcAsync(provider, code, state); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during OIDC authentication"); + return new AuthResult + { + Success = false, + Error = "Authentication failed. Please try again." + }; + } + } + + public virtual IEnumerable GetSupportedProviders() + { + var section = _configuration.GetSection("Oidc"); + return section.GetChildren().Select(x => x.Key); + } + + protected virtual async Task FindOrCreateUser(OidcUserInfo userInfo, string provider) + { + // Check if user exists with this provider and subject + var user = await _db.Accounts + .FirstOrDefaultAsync(u => u.ExternalLogins.Any(ul => + ul.Provider == provider && + ul.ProviderSubjectId == userInfo.Subject)); + + if (user != null) + return user; + + // If user doesn't exist, create a new one + user = new Account + { + Id = Guid.NewGuid(), + Username = userInfo.PreferredUsername ?? userInfo.Email?.Split('@')[0] ?? Guid.NewGuid().ToString(), + Email = userInfo.Email, + EmailVerified = userInfo.EmailVerified ?? false, + CreatedAt = SystemClock.Instance.GetCurrentInstant() + }; + + // Add external login + user.ExternalLogins.Add(new ExternalLogin + { + Id = Guid.NewGuid(), + Provider = provider, + ProviderSubjectId = userInfo.Subject, + CreatedAt = SystemClock.Instance.GetCurrentInstant() + }); + + await _db.Accounts.AddAsync(user); + await _db.SaveChangesAsync(); + + return user; + } + + protected virtual async Task ValidateIdToken(string token, string issuer, string audience, string signingKey) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)); + + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }, out var validatedToken); + + return (JwtSecurityToken)validatedToken; + } + + protected virtual async Task GetFromDiscoveryDocumentAsync(string url) + { + var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content); + } +} diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs index f4d8361..48cb181 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs @@ -1,47 +1,83 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NodaTime; using DysonNetwork.Common.Models; using DysonNetwork.Pass.Data; -using DysonNetwork.Sphere; +using DysonNetwork.Pass.Features.Auth.Models; +using DysonNetwork.Pass.Features.Auth.Services; +using Microsoft.IdentityModel.Tokens; + +// Use fully qualified names to avoid ambiguity +using CommonAccount = DysonNetwork.Common.Models.Account; +using CommonOidcUserInfo = DysonNetwork.Common.Models.OidcUserInfo; namespace DysonNetwork.Pass.Features.Auth.OpenId; [ApiController] [Route("/auth/login")] -public class OidcController( - IServiceProvider serviceProvider, - PassDatabase passDb, - AppDatabase sphereDb, - AccountService accounts, - ICacheService cache -) - : ControllerBase +public class OidcController : ControllerBase { private const string StateCachePrefix = "oidc-state:"; private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15); + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly PassDatabase _db; + private readonly IAccountService _accountService; + private readonly IAccountConnectionService _connectionService; + private readonly ICacheService _cache; + + public OidcController( + IServiceProvider serviceProvider, + PassDatabase db, + IAccountService accountService, + IAccountConnectionService connectionService, + ICacheService cache, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _db = db ?? throw new ArgumentNullException(nameof(db)); + _accountService = accountService ?? throw new ArgumentNullException(nameof(accountService)); + _connectionService = connectionService ?? throw new ArgumentNullException(nameof(connectionService)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } [HttpGet("{provider}")] public async Task OidcLogin( [FromRoute] string provider, [FromQuery] string? returnUrl = "/", - [FromHeader(Name = "X-Device-Id")] string? deviceId = null - ) + [FromHeader(Name = "X-Device-Id")] string? deviceId = null) { try { var oidcService = GetOidcService(provider); // If the user is already authenticated, treat as an account connection request - if (HttpContext.Items["CurrentUser"] is Account currentUser) + var currentUser = await HttpContext.AuthenticateAsync(); + if (currentUser.Succeeded && currentUser.Principal?.Identity?.IsAuthenticated == true) { var state = Guid.NewGuid().ToString(); var nonce = Guid.NewGuid().ToString(); + // Get the current user's account ID + var accountId = currentUser.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(accountId)) + { + _logger.LogWarning("Authenticated user does not have a valid account ID"); + return Unauthorized(); + } + // Create and store connection state - var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId); - await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); + var oidcState = OidcState.ForConnection(accountId, provider, nonce, deviceId); + await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); // The state parameter sent to the provider is the GUID key for the cache. var authUrl = oidcService.GetAuthorizationUrl(state, nonce); @@ -49,12 +85,15 @@ public class OidcController( } else // Otherwise, proceed with the login / registration flow { - var nonce = Guid.NewGuid().ToString(); var state = Guid.NewGuid().ToString(); + var nonce = Guid.NewGuid().ToString(); - // Create login state with return URL and device ID + // Store the state and nonce for validation later var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId); - await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); + oidcState.Provider = provider; + oidcState.Nonce = nonce; + await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); + var authUrl = oidcService.GetAuthorizationUrl(state, nonce); return Redirect(authUrl); } @@ -70,7 +109,7 @@ public class OidcController( /// Handles Apple authentication directly from mobile apps /// [HttpPost("apple/mobile")] - public async Task> AppleMobileSignIn( + public async Task> AppleMobileSignIn( [FromBody] AppleMobileSignInRequest request) { try @@ -100,6 +139,11 @@ public class OidcController( request.DeviceId ); + if (challenge == null) + { + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create authentication challenge"); + } + return Ok(challenge); } catch (SecurityTokenValidationException ex) @@ -113,85 +157,141 @@ public class OidcController( } } + private async Task HandleLogin(OidcState oidcState, OidcUserInfo userInfo) + { + try + { + // Find or create the account + var account = await _accountService.FindOrCreateAccountAsync(userInfo, oidcState.Provider ?? throw new InvalidOperationException("Provider not specified")); + if (account == null) + { + _logger.LogError("Failed to find or create account for user {UserId}", userInfo.UserId); + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to process your account"); + } + + // Create a new session + var session = await _connectionService.CreateSessionAsync(account, oidcState.DeviceId); + if (session == null) + { + _logger.LogError("Failed to create session for account {AccountId}", account.Id); + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create session"); + } + + // Create auth tokens + var tokens = await _accountService.GenerateAuthTokensAsync(account, session.Id.ToString()); + + // Return the tokens and redirect URL + return Ok(new + { + tokens.AccessToken, + tokens.RefreshToken, + tokens.ExpiresIn, + ReturnUrl = oidcState.ReturnUrl ?? "/" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling OIDC login for user {UserId}", userInfo.UserId); + return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred during login"); + } + } + + private async Task HandleAccountConnection(OidcState oidcState, OidcUserInfo userInfo) + { + try + { + // Get the current user's account + if (!Guid.TryParse(oidcState.AccountId, out var accountId)) + { + _logger.LogError("Invalid account ID format: {AccountId}", oidcState.AccountId); + return BadRequest("Invalid account ID format"); + } + + var account = await _accountService.GetAccountByIdAsync(accountId); + if (account == null) + { + _logger.LogError("Account not found for ID {AccountId}", accountId); + return Unauthorized(); + } + + // Add the OIDC connection to the account + var connection = await _connectionService.AddConnectionAsync(account, userInfo, oidcState.Provider!); + if (connection == null) + { + _logger.LogError("Failed to add OIDC connection for account {AccountId}", account.Id); + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to add OIDC connection"); + } + + // Return success + return Ok(new { Success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling OIDC account connection for user {UserId}", userInfo.UserId); + return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while connecting your account"); + } + } + private OidcService GetOidcService(string provider) { return provider.ToLower() switch { - "apple" => serviceProvider.GetRequiredService(), - "google" => serviceProvider.GetRequiredService(), - "microsoft" => serviceProvider.GetRequiredService(), - "discord" => serviceProvider.GetRequiredService(), - "github" => serviceProvider.GetRequiredService(), - "afdian" => serviceProvider.GetRequiredService(), + "apple" => _serviceProvider.GetRequiredService(), + "google" => _serviceProvider.GetRequiredService(), + "microsoft" => _serviceProvider.GetRequiredService(), + "discord" => _serviceProvider.GetRequiredService(), + "github" => _serviceProvider.GetRequiredService(), + "afdian" => _serviceProvider.GetRequiredService(), _ => throw new ArgumentException($"Unsupported provider: {provider}") }; } - private async Task FindOrCreateAccount(OidcUserInfo userInfo, string provider) + private async Task FindOrCreateAccount(CommonOidcUserInfo userInfo, string provider) { if (string.IsNullOrEmpty(userInfo.Email)) throw new ArgumentException("Email is required for account creation"); - // Check if an account exists by email - var existingAccount = await accounts.LookupAccount(userInfo.Email); - if (existingAccount != null) + // Find or create the account connection + var connection = await _connectionService.FindOrCreateConnection(userInfo, provider); + + // If connection already has an account, return it + if (!string.IsNullOrEmpty(connection.AccountId)) { - // Check if this provider connection already exists - var existingConnection = await passDb.AccountConnections - .FirstOrDefaultAsync(c => c.Provider == provider && - c.ProvidedIdentifier == userInfo.UserId && - c.AccountId == existingAccount.Id - ); - - // If no connection exists, create one - if (existingConnection != null) + if (Guid.TryParse(connection.AccountId, out var accountId)) { - await passDb.AccountConnections - .Where(c => c.AccountId == existingAccount.Id && - c.Provider == provider && - c.ProvidedIdentifier == userInfo.UserId) - .ExecuteUpdateAsync(s => s - .SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant()) - .SetProperty(c => c.Meta, userInfo.ToMetadata())); - - return existingAccount; + var existingAccount = await _accountService.GetAccountByIdAsync(accountId); + if (existingAccount != null) + { + await _connectionService.UpdateConnection(connection, userInfo); + return existingAccount; + } } - - var connection = new AccountConnection - { - AccountId = existingAccount.Id, - Provider = provider, - ProvidedIdentifier = userInfo.UserId!, - AccessToken = userInfo.AccessToken, - RefreshToken = userInfo.RefreshToken, - LastUsedAt = SystemClock.Instance.GetCurrentInstant(), - Meta = userInfo.ToMetadata() - }; - - await passDb.AccountConnections.AddAsync(connection); - await passDb.SaveChangesAsync(); - - return existingAccount; } - // Create new account using the AccountService - var newAccount = await accounts.CreateAccount(userInfo); - - // Create the provider connection - var newConnection = new AccountConnection + // Check if account exists by email + var account = await _accountService.FindByEmailAsync(userInfo.Email); + if (account == null) { - AccountId = newAccount.Id, - Provider = provider, - ProvidedIdentifier = userInfo.UserId!, - AccessToken = userInfo.AccessToken, - RefreshToken = userInfo.RefreshToken, - LastUsedAt = SystemClock.Instance.GetCurrentInstant(), - Meta = userInfo.ToMetadata() - }; + // Create new account using the account service + account = new CommonAccount + { + Id = Guid.NewGuid().ToString(), + Email = userInfo.Email, + Name = userInfo.Name ?? userInfo.Email, + CreatedAt = SystemClock.Instance.GetCurrentInstant() + }; + + // Save the new account + account = await _accountService.CreateAccountAsync(account); + } - await passDb.AccountConnections.Add(newConnection); - await passDb.SaveChangesAsync(); + // Update connection with account ID if needed + if (string.IsNullOrEmpty(connection.AccountId)) + { + connection.AccountId = account.Id; + await _connectionService.UpdateConnection(connection, userInfo); + } - return newAccount; + return account; } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Features/Auth/Services/SessionService.cs b/DysonNetwork.Pass/Features/Auth/Services/SessionService.cs new file mode 100644 index 0000000..0dd1552 --- /dev/null +++ b/DysonNetwork.Pass/Features/Auth/Services/SessionService.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using System.Text; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Pass.Features.Auth.Interfaces; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Features.Auth.Services; + +public class SessionService : ISessionService +{ + private readonly PassDatabase _db; + private readonly IClock _clock; + + public SessionService(PassDatabase db, IClock clock) + { + _db = db; + _clock = clock; + } + + public async Task CreateSessionAsync(Guid accountId, string ipAddress, string userAgent) + { + var now = _clock.GetCurrentInstant(); + var session = new AuthSession + { + Id = Guid.NewGuid(), + AccountId = accountId, + Label = $"Session from {ipAddress} via {userAgent}", + LastGrantedAt = now, + ExpiredAt = now.Plus(Duration.FromDays(30)) + }; + + await _db.AuthSessions.AddAsync(session); + await _db.SaveChangesAsync(); + + return session; + } + + public async Task GetSessionAsync(Guid sessionId) + { + return await _db.AuthSessions + .Include(s => s.Account) + .FirstOrDefaultAsync(s => s.Id == sessionId && s.ExpiredAt > _clock.GetCurrentInstant()); + } + + public async Task ValidateSessionAsync(Guid sessionId) + { + var session = await GetSessionAsync(sessionId); + if (session == null) + return false; + + var now = _clock.GetCurrentInstant(); + if (session.ExpiredAt <= now) + return false; + + session.LastGrantedAt = now; + await _db.SaveChangesAsync(); + + return true; + } + + public async Task InvalidateSessionAsync(Guid sessionId) + { + var session = await GetSessionAsync(sessionId); + if (session != null) + { + session.ExpiredAt = _clock.GetCurrentInstant(); + await _db.SaveChangesAsync(); + } + } + + public async Task InvalidateAllSessionsAsync(Guid accountId, Guid? excludeSessionId = null) + { + var now = _clock.GetCurrentInstant(); + var sessions = await _db.AuthSessions + .Where(s => s.AccountId == accountId && s.ExpiredAt > now) + .ToListAsync(); + + foreach (var session in sessions) + { + if (excludeSessionId == null || session.Id != excludeSessionId.Value) + { + session.ExpiredAt = now; + } + } + + await _db.SaveChangesAsync(); + } + + public async Task UpdateSessionActivityAsync(Guid sessionId) + { + var session = await GetSessionAsync(sessionId); + if (session != null) + { + session.LastGrantedAt = _clock.GetCurrentInstant(); + await _db.SaveChangesAsync(); + } + } + + private static string GenerateRefreshToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } +} diff --git a/DysonNetwork.Pass/Models/ModelBase.cs b/DysonNetwork.Pass/Models/ModelBase.cs new file mode 100644 index 0000000..1f9816f --- /dev/null +++ b/DysonNetwork.Pass/Models/ModelBase.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; +using NodaTime; + +namespace DysonNetwork.Pass.Models; + +public abstract class ModelBase +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + public Instant CreatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant(); + public Instant? UpdatedAt { get; set; } + public Instant? DeletedAt { get; set; } + + public bool IsDeleted => DeletedAt != null; + + public void MarkAsUpdated() + { + UpdatedAt = SystemClock.Instance.GetCurrentInstant(); + } + + public void MarkAsDeleted() + { + if (DeletedAt == null) + { + DeletedAt = SystemClock.Instance.GetCurrentInstant(); + MarkAsUpdated(); + } + } + + public void Restore() + { + if (DeletedAt != null) + { + DeletedAt = null; + MarkAsUpdated(); + } + } +} diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index f1b98fd..52e80ec 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -38,7 +38,8 @@ builder.Services.AddDbContext(options => // Add custom services builder.Services.AddScoped(); -builder.Services.AddScoped(); +// Old AuthService is being replaced with the new authentication services +// builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -47,6 +48,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Add authentication services +builder.Services.AddAuthServices(); + // Add OIDC services builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index bdaddf0..12aa7e0 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -88,19 +88,13 @@ - - - - - - diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index bacd435..7d1f372 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -28,7 +28,7 @@ using DysonNetwork.Sphere.Safety; using DysonNetwork.Sphere.Wallet.PaymentHandlers; using tusdotnet.Stores; using DysonNetwork.Common.Interfaces; -using DysonNetwork.Drive.Clients; +using DysonNetwork.Common.Clients; using DysonNetwork.Sphere.Data; using Npgsql.EntityFrameworkCore.PostgreSQL; using Microsoft.EntityFrameworkCore;