:drunk: No idea what did AI did

This commit is contained in:
2025-07-06 19:46:59 +08:00
parent 14b79f16f4
commit 3391c08c04
40 changed files with 2484 additions and 112 deletions

View File

@ -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<FileReferenceServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileReferenceServiceClient(HttpClient httpClient, ILogger<FileReferenceServiceClient> 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<CloudFileReference> 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<CloudFileReference>(stream, _jsonOptions);
return reference;
}
public async Task<CloudFileReference> 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<CloudFileReference>(stream, _jsonOptions);
return reference;
}
public async Task<IEnumerable<CloudFileReference>> 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<IEnumerable<CloudFileReference>>(stream, _jsonOptions);
return references ?? Array.Empty<CloudFileReference>();
}
public async Task DeleteReferenceAsync(string referenceId)
{
var response = await _httpClient.DeleteAsync($"api/filereferences/{referenceId}");
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
_httpClient?.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@ -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<FileServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileServiceClient(HttpClient httpClient, ILogger<FileServiceClient> 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<CloudFile> 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<CloudFile>(stream, _jsonOptions);
return file;
}
public async Task<Stream> DownloadFileAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/files/{fileId}/download");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync();
}
public async Task<CloudFile> 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<CloudFile>(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);
}
}
}

View File

@ -0,0 +1,81 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Common.Models;
/// <summary>
/// Represents a connection between an account and an authentication provider
/// </summary>
public class AccountConnection
{
/// <summary>
/// Unique identifier for the connection
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; } = null!;
/// <summary>
/// The account ID this connection is associated with
/// </summary>
public string? AccountId { get; set; }
/// <summary>
/// The authentication provider (e.g., "google", "github")
/// </summary>
[Required]
[MaxLength(50)]
public string Provider { get; set; } = null!;
/// <summary>
/// The unique identifier for the user from the provider
/// </summary>
[Required]
[MaxLength(256)]
public string ProvidedIdentifier { get; set; } = null!;
/// <summary>
/// Display name for the connection
/// </summary>
[MaxLength(100)]
public string? DisplayName { get; set; }
/// <summary>
/// OAuth access token from the provider
/// </summary>
public string? AccessToken { get; set; }
/// <summary>
/// OAuth refresh token from the provider (if available)
/// </summary>
public string? RefreshToken { get; set; }
/// <summary>
/// When the access token expires (if available)
/// </summary>
public Instant? ExpiresAt { get; set; }
/// <summary>
/// When the connection was first established
/// </summary>
public Instant CreatedAt { get; set; }
/// <summary>
/// When the connection was last used
/// </summary>
public Instant? LastUsedAt { get; set; }
/// <summary>
/// Additional metadata about the connection
/// </summary>
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Meta { get; set; }
/// <summary>
/// Navigation property for the associated account
/// </summary>
[ForeignKey(nameof(AccountId))]
public virtual Account? Account { get; set; }
}

View File

@ -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
};
}
}

View File

@ -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
}

View File

@ -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; }
}

View File

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
namespace DysonNetwork.Common.Models;
/// <summary>
/// Represents user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
/// <summary>
/// The unique identifier for the user from the OIDC provider
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// The user's email address
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Whether the user's email has been verified by the OIDC provider
/// </summary>
public bool EmailVerified { get; set; }
/// <summary>
/// The user's given name (first name)
/// </summary>
public string? GivenName { get; set; }
/// <summary>
/// The user's family name (last name)
/// </summary>
public string? FamilyName { get; set; }
/// <summary>
/// The user's full name
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The user's preferred username
/// </summary>
public string? PreferredUsername { get; set; }
/// <summary>
/// URL to the user's profile picture
/// </summary>
public string? Picture { get; set; }
/// <summary>
/// The OIDC provider name (e.g., "google", "github")
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// OAuth access token from the provider
/// </summary>
public string? AccessToken { get; set; }
/// <summary>
/// OAuth refresh token from the provider (if available)
/// </summary>
public string? RefreshToken { get; set; }
/// <summary>
/// When the access token expires (if available)
/// </summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Additional claims from the ID token or user info endpoint
/// </summary>
public Dictionary<string, object>? Claims { get; set; }
/// <summary>
/// Converts the user info to a metadata dictionary for storage
/// </summary>
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
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;
}
}

View File

@ -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<Type, object> _buffers = new();
private readonly object _lockObject = new();
private ConcurrentQueue<T> GetOrCreateBuffer<T>()
{
var type = typeof(T);
lock (_lockObject)
{
if (!_buffers.TryGetValue(type, out var buffer))
{
buffer = new ConcurrentQueue<T>();
_buffers[type] = buffer;
}
return (ConcurrentQueue<T>)buffer;
}
}
public void Enqueue<T>(T item)
{
var buffer = GetOrCreateBuffer<T>();
buffer.Enqueue(item);
}
public async Task FlushAsync<T>(IFlushHandler<T> handler)
{
var buffer = GetOrCreateBuffer<T>();
var workingQueue = new List<T>();
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<T>()
{
var buffer = GetOrCreateBuffer<T>();
return buffer.Count;
}
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Common.Services;
public interface IFlushHandler<T>
{
Task FlushAsync(IReadOnlyList<T> items);
}

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
</ItemGroup>
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
@ -12,11 +12,13 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Minio" Version="6.0.5" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="tusdotnet" Version="2.10.0" />
@ -26,6 +28,8 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1701;1702;1591</NoWarn>
</PropertyGroup>
</Project>

View File

@ -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<LastActiveInfo>
{
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> 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)

View File

@ -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<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Account> Accounts { get; set; } = null!;
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<Badge> Badges { get; set; }
@ -77,6 +81,213 @@ public class PassDatabase(
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
// Configure AuthSession
modelBuilder.Entity<AuthSession>(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<AuthChallenge>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Type)
.IsRequired()
.HasConversion<string>();
entity.Property(e => e.Platform)
.IsRequired()
.HasConversion<string>();
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<Account>()
.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<AccountAuthFactor>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.FactorType)
.IsRequired()
.HasConversion<string>();
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<Account>(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<AccountConnection>(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<Account>()
.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())
{

View File

@ -44,7 +44,6 @@
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
</ItemGroup>

View File

@ -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<AuthResult> AuthenticateAsync(string username, string password);
Task<AuthResult> AuthenticateWithOidcAsync(string provider, string code, string state);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task<bool> ValidateTokenAsync(string token);
Task LogoutAsync(Guid sessionId);
Task<bool> ValidateSessionAsync(Guid sessionId);
Task<AuthSession> GetSessionAsync(Guid sessionId);
}

View File

@ -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<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
Task<AuthResult> AuthenticateAsync(string provider, string code, string state);
IEnumerable<string> GetSupportedProviders();
}

View File

@ -0,0 +1,14 @@
using DysonNetwork.Common.Models;
using NodaTime;
namespace DysonNetwork.Pass.Features.Auth.Interfaces;
public interface ISessionService
{
Task<AuthSession> CreateSessionAsync(Guid accountId, string ipAddress, string userAgent);
Task<AuthSession?> GetSessionAsync(Guid sessionId);
Task<bool> ValidateSessionAsync(Guid sessionId);
Task InvalidateSessionAsync(Guid sessionId);
Task InvalidateAllSessionsAsync(Guid accountId, Guid? excludeSessionId = null);
Task UpdateSessionActivityAsync(Guid sessionId);
}

View File

@ -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<AuthSession> Sessions { get; set; } = new List<AuthSession>();
[JsonIgnore]
public virtual ICollection<AuthChallenge> Challenges { get; set; } = new List<AuthChallenge>();
[JsonIgnore]
public virtual ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore]
public virtual ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
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
}
};
}
}

View File

@ -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<AuthSession> Sessions { get; set; } = new List<AuthSession>();
public void UpdateMetadata(Action<JsonDocument> 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();
}
}

View File

@ -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;
}
}

View File

@ -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<AuthSession> Sessions { get; set; } = new List<AuthSession>();
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<string> factors)
{
BlacklistFactors = JsonSerializer.SerializeToDocument(factors);
}
public void UpdateAudiences(IEnumerable<string> audiences)
{
Audiences = JsonSerializer.SerializeToDocument(audiences);
}
public void UpdateScopes(IEnumerable<string> 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;
}
}

View File

@ -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
}

View File

@ -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<string>? RequiredFactors { get; set; }
}

View File

@ -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<string, object>? 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();
}
}

View File

@ -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";
}

View File

@ -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; }
}

View File

@ -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<string, string>? Address { get; set; }
[JsonPropertyName("updated_at")]
public long? UpdatedAt { get; set; }
// Custom claims
[JsonExtensionData]
public Dictionary<string, object>? AdditionalData { get; set; }
}

View File

@ -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<ISessionService, SessionService>();
services.AddScoped<IAuthenticationService, AuthenticationService>();
// OIDC services will be registered by their respective implementations
services.AddScoped<IOidcService, OidcService>();
// Add HTTP context accessor if not already added
services.AddHttpContextAccessor();
return services;
}
}

View File

@ -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<CommonAccountConnection> 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<CommonAccountConnection?> 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<Models.AuthSession> 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<CommonAccountConnection> 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;
}
}

View File

@ -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<Account> 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<Account?> 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<Account?> 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<Account> 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<Account?> 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<AuthTokens> 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"
};
}
}

View File

@ -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);

View File

@ -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<AuthResult> 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<bool> 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<AuthResult> AuthenticateWithOidcAsync(string provider, string code, string state)
{
return await _oidcService.AuthenticateAsync(provider, code, state);
}
public async Task<AuthResult> 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<bool> 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<bool> ValidateSessionAsync(Guid sessionId)
{
return await _sessionService.ValidateSessionAsync(sessionId);
}
public async Task<AuthSession> GetSessionAsync(Guid sessionId)
{
var session = await _sessionService.GetSessionAsync(sessionId);
if (session == null)
throw new Exception("Session not found");
return session;
}
private async Task<AuthResult> 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);
}
}

View File

@ -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
{
/// <summary>
/// Finds an existing account connection or creates a new one
/// </summary>
Task<Common.Models.AccountConnection> FindOrCreateConnection(Common.Models.OidcUserInfo userInfo, string provider);
/// <summary>
/// Updates an existing connection with new token information
/// </summary>
Task UpdateConnection(Common.Models.AccountConnection connection, Common.Models.OidcUserInfo userInfo);
/// <summary>
/// Finds an account connection by provider and user ID
/// </summary>
Task<Common.Models.AccountConnection?> FindConnection(string provider, string userId);
/// <summary>
/// Creates a new session for the specified account
/// </summary>
Task<Models.AuthSession> CreateSessionAsync(Common.Models.Account account, string? deviceId = null);
/// <summary>
/// Adds a new OIDC connection to an account
/// </summary>
Task<Common.Models.AccountConnection> AddConnectionAsync(Common.Models.Account account, Common.Models.OidcUserInfo userInfo, string provider);
}

View File

@ -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
{
/// <summary>
/// Creates a new account from OIDC user info
/// </summary>
Task<Common.Models.Account> CreateAccount(Common.Models.OidcUserInfo userInfo);
/// <summary>
/// Finds an account by email
/// </summary>
Task<Common.Models.Account?> FindByEmailAsync(string email);
/// <summary>
/// Finds an account by ID
/// </summary>
Task<Common.Models.Account?> FindByIdAsync(string accountId);
/// <summary>
/// Updates an existing account
/// </summary>
Task UpdateAccount(Common.Models.Account account);
/// <summary>
/// Finds or creates an account based on OIDC user info
/// </summary>
Task<Common.Models.Account> FindOrCreateAccountAsync(Common.Models.OidcUserInfo userInfo, string provider);
/// <summary>
/// Gets an account by ID
/// </summary>
Task<Common.Models.Account?> GetAccountByIdAsync(Guid accountId);
/// <summary>
/// Generates authentication tokens for an account
/// </summary>
Task<AuthTokens> GenerateAuthTokensAsync(Common.Models.Account account, string sessionId);
}

View File

@ -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<OidcService> _logger;
public OidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
PassDatabase db,
IAuthenticationService authService,
ILogger<OidcService> 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<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
throw new NotImplementedException("This method should be implemented by derived classes");
}
public virtual async Task<AuthResult> 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<string> GetSupportedProviders()
{
var section = _configuration.GetSection("Oidc");
return section.GetChildren().Select(x => x.Key);
}
protected virtual async Task<Account> 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<JwtSecurityToken> 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<T?> GetFromDiscoveryDocumentAsync<T>(string url)
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content);
}
}

View File

@ -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<OidcController> _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<OidcController> 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<ActionResult> 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
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<AuthChallenge>> AppleMobileSignIn(
public async Task<ActionResult<Models.AuthChallenge>> 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<IActionResult> 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<IActionResult> 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<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
"apple" => _serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => _serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => _serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => _serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => _serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => _serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
private async Task<CommonAccount> 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;
}
}

View File

@ -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<AuthSession> 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<AuthSession?> GetSessionAsync(Guid sessionId)
{
return await _db.AuthSessions
.Include(s => s.Account)
.FirstOrDefaultAsync(s => s.Id == sessionId && s.ExpiredAt > _clock.GetCurrentInstant());
}
public async Task<bool> 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);
}
}

View File

@ -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();
}
}
}

View File

@ -38,7 +38,8 @@ builder.Services.AddDbContext<PassDatabase>(options =>
// Add custom services
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>();
// Old AuthService is being replaced with the new authentication services
// builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<MagicSpellService>();
builder.Services.AddScoped<AccountEventService>();
builder.Services.AddScoped<AccountUsernameService>();
@ -47,6 +48,9 @@ builder.Services.AddScoped<RelationshipService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<PermissionService>();
// Add authentication services
builder.Services.AddAuthServices();
// Add OIDC services
builder.Services.AddScoped<OidcProviderService>();
builder.Services.AddScoped<AppleOidcService>();

View File

@ -88,19 +88,13 @@
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
<Folder Include="Discovery\" />
<Folder Include="Services\PassClient\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
</ItemGroup>

View File

@ -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;