:drunk: No idea what did AI did
This commit is contained in:
98
DysonNetwork.Common/Clients/FileReferenceServiceClient.cs
Normal file
98
DysonNetwork.Common/Clients/FileReferenceServiceClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
77
DysonNetwork.Common/Clients/FileServiceClient.cs
Normal file
77
DysonNetwork.Common/Clients/FileServiceClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
81
DysonNetwork.Common/Models/AccountConnection.cs
Normal file
81
DysonNetwork.Common/Models/AccountConnection.cs
Normal 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; }
|
||||||
|
}
|
@ -66,3 +66,25 @@ public class AuthChallenge : ModelBase
|
|||||||
return this;
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
74
DysonNetwork.Common/Models/Auth/Enums.cs
Normal file
74
DysonNetwork.Common/Models/Auth/Enums.cs
Normal 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
|
||||||
|
}
|
10
DysonNetwork.Common/Models/LastActiveInfo.cs
Normal file
10
DysonNetwork.Common/Models/LastActiveInfo.cs
Normal 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; }
|
||||||
|
}
|
114
DysonNetwork.Common/Models/OidcUserInfo.cs
Normal file
114
DysonNetwork.Common/Models/OidcUserInfo.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
64
DysonNetwork.Common/Services/FlushBufferService.cs
Normal file
64
DysonNetwork.Common/Services/FlushBufferService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
9
DysonNetwork.Common/Services/IFlushHandler.cs
Normal file
9
DysonNetwork.Common/Services/IFlushHandler.cs
Normal 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);
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -12,11 +12,13 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" 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="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.6" />
|
||||||
<PackageReference Include="Minio" Version="6.0.5" />
|
<PackageReference Include="Minio" Version="6.0.5" />
|
||||||
<PackageReference Include="NetVips" Version="3.1.0" />
|
<PackageReference Include="NetVips" Version="3.1.0" />
|
||||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
<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="Quartz" Version="3.14.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
|
||||||
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
||||||
@ -26,6 +28,8 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>1701;1702;1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -4,16 +4,11 @@ using Quartz;
|
|||||||
using DysonNetwork.Drive.Auth;
|
using DysonNetwork.Drive.Auth;
|
||||||
using DysonNetwork.Drive.Models;
|
using DysonNetwork.Drive.Models;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using DysonNetwork.Common.Models;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Handlers;
|
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 class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<LastActiveInfo>
|
||||||
{
|
{
|
||||||
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
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
|
// Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
|
||||||
var distinctItems = items
|
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())
|
.Select(g => g.OrderByDescending(x => x.SeenAt).First())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Build dictionaries so we can match session/account IDs to their new "last seen" timestamps
|
// Build dictionaries so we can match session/account IDs to their new "last seen" timestamps
|
||||||
var sessionIdMap = distinctItems
|
var sessionIdMap = distinctItems
|
||||||
.GroupBy(x => x.SessionId)
|
.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
|
var accountIdMap = distinctItems
|
||||||
.GroupBy(x => x.AccountId)
|
.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
|
// Update sessions using native EF Core ExecuteUpdateAsync
|
||||||
foreach (var kvp in sessionIdMap)
|
foreach (var kvp in sessionIdMap)
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DysonNetwork.Common.Models;
|
using DysonNetwork.Common.Models;
|
||||||
|
using DysonNetwork.Pass.Features.Auth.Models;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Sphere.Permission;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
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;
|
namespace DysonNetwork.Pass.Data;
|
||||||
|
|
||||||
@ -19,11 +25,9 @@ public class PassDatabase(
|
|||||||
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
||||||
|
|
||||||
public DbSet<MagicSpell> MagicSpells { get; set; }
|
public DbSet<MagicSpell> MagicSpells { get; set; }
|
||||||
public DbSet<Account> Accounts { get; set; }
|
public DbSet<Account> Accounts { get; set; } = null!;
|
||||||
public DbSet<AccountConnection> AccountConnections { get; set; }
|
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
|
||||||
public DbSet<Profile> AccountProfiles { get; set; }
|
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
|
||||||
public DbSet<AccountContact> AccountContacts { get; set; }
|
|
||||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
|
||||||
public DbSet<Relationship> AccountRelationships { get; set; }
|
public DbSet<Relationship> AccountRelationships { get; set; }
|
||||||
public DbSet<Notification> Notifications { get; set; }
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
public DbSet<Badge> Badges { get; set; }
|
public DbSet<Badge> Badges { get; set; }
|
||||||
@ -77,6 +81,213 @@ public class PassDatabase(
|
|||||||
.WithMany(a => a.IncomingRelationships)
|
.WithMany(a => a.IncomingRelationships)
|
||||||
.HasForeignKey(r => r.RelatedId);
|
.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
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
{
|
{
|
||||||
|
@ -44,7 +44,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
12
DysonNetwork.Pass/Features/Auth/Interfaces/IOidcService.cs
Normal file
12
DysonNetwork.Pass/Features/Auth/Interfaces/IOidcService.cs
Normal 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();
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
80
DysonNetwork.Pass/Features/Auth/Models/Account.cs
Normal file
80
DysonNetwork.Pass/Features/Auth/Models/Account.cs
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
74
DysonNetwork.Pass/Features/Auth/Models/AccountAuthFactor.cs
Normal file
74
DysonNetwork.Pass/Features/Auth/Models/AccountAuthFactor.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
70
DysonNetwork.Pass/Features/Auth/Models/AccountConnection.cs
Normal file
70
DysonNetwork.Pass/Features/Auth/Models/AccountConnection.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
122
DysonNetwork.Pass/Features/Auth/Models/AuthChallenge.cs
Normal file
122
DysonNetwork.Pass/Features/Auth/Models/AuthChallenge.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
13
DysonNetwork.Pass/Features/Auth/Models/AuthFactorType.cs
Normal file
13
DysonNetwork.Pass/Features/Auth/Models/AuthFactorType.cs
Normal 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
|
||||||
|
}
|
13
DysonNetwork.Pass/Features/Auth/Models/AuthResult.cs
Normal file
13
DysonNetwork.Pass/Features/Auth/Models/AuthResult.cs
Normal 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; }
|
||||||
|
}
|
87
DysonNetwork.Pass/Features/Auth/Models/AuthSession.cs
Normal file
87
DysonNetwork.Pass/Features/Auth/Models/AuthSession.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
9
DysonNetwork.Pass/Features/Auth/Models/AuthTokens.cs
Normal file
9
DysonNetwork.Pass/Features/Auth/Models/AuthTokens.cs
Normal 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";
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
70
DysonNetwork.Pass/Features/Auth/Models/OidcUserInfo.cs
Normal file
70
DysonNetwork.Pass/Features/Auth/Models/OidcUserInfo.cs
Normal 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; }
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
181
DysonNetwork.Pass/Features/Auth/Services/AccountService.cs
Normal file
181
DysonNetwork.Pass/Features/Auth/Services/AccountService.cs
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -4,13 +4,13 @@ using System.Text.Encodings.Web;
|
|||||||
using DysonNetwork.Pass.Features.Account;
|
using DysonNetwork.Pass.Features.Account;
|
||||||
using DysonNetwork.Pass.Features.Auth.OidcProvider.Services;
|
using DysonNetwork.Pass.Features.Auth.OidcProvider.Services;
|
||||||
using DysonNetwork.Common.Services;
|
using DysonNetwork.Common.Services;
|
||||||
using DysonNetwork.Drive.Handlers;
|
using DysonNetwork.Common.Services;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using DysonNetwork.Pass.Data;
|
using DysonNetwork.Pass.Data;
|
||||||
using DysonNetwork.Common.Models;
|
using DysonNetwork.Common.Models;
|
||||||
using DysonNetwork.Drive;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Features.Auth.Services;
|
namespace DysonNetwork.Pass.Features.Auth.Services;
|
||||||
|
|
||||||
@ -125,10 +125,10 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
||||||
|
|
||||||
var lastInfo = new LastActiveInfo
|
var lastInfo = new DysonNetwork.Common.Models.LastActiveInfo
|
||||||
{
|
{
|
||||||
Account = session.Account,
|
AccountId = session.Account.Id.ToString(),
|
||||||
Session = session,
|
SessionId = session.Id.ToString(),
|
||||||
SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
|
SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
|
||||||
};
|
};
|
||||||
fbs.Enqueue(lastInfo);
|
fbs.Enqueue(lastInfo);
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
44
DysonNetwork.Pass/Features/Auth/Services/IAccountService.cs
Normal file
44
DysonNetwork.Pass/Features/Auth/Services/IAccountService.cs
Normal 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);
|
||||||
|
}
|
144
DysonNetwork.Pass/Features/Auth/Services/OidcService.cs
Normal file
144
DysonNetwork.Pass/Features/Auth/Services/OidcService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.Extensions.Logging;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using DysonNetwork.Common.Models;
|
using DysonNetwork.Common.Models;
|
||||||
using DysonNetwork.Pass.Data;
|
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;
|
namespace DysonNetwork.Pass.Features.Auth.OpenId;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/auth/login")]
|
[Route("/auth/login")]
|
||||||
public class OidcController(
|
public class OidcController : ControllerBase
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
PassDatabase passDb,
|
|
||||||
AppDatabase sphereDb,
|
|
||||||
AccountService accounts,
|
|
||||||
ICacheService cache
|
|
||||||
)
|
|
||||||
: ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string StateCachePrefix = "oidc-state:";
|
private const string StateCachePrefix = "oidc-state:";
|
||||||
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
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}")]
|
[HttpGet("{provider}")]
|
||||||
public async Task<ActionResult> OidcLogin(
|
public async Task<ActionResult> OidcLogin(
|
||||||
[FromRoute] string provider,
|
[FromRoute] string provider,
|
||||||
[FromQuery] string? returnUrl = "/",
|
[FromQuery] string? returnUrl = "/",
|
||||||
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
|
[FromHeader(Name = "X-Device-Id")] string? deviceId = null)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var oidcService = GetOidcService(provider);
|
var oidcService = GetOidcService(provider);
|
||||||
|
|
||||||
// If the user is already authenticated, treat as an account connection request
|
// 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 state = Guid.NewGuid().ToString();
|
||||||
var nonce = 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
|
// Create and store connection state
|
||||||
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
|
var oidcState = OidcState.ForConnection(accountId, provider, nonce, deviceId);
|
||||||
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||||
|
|
||||||
// The state parameter sent to the provider is the GUID key for the cache.
|
// The state parameter sent to the provider is the GUID key for the cache.
|
||||||
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||||
@ -49,12 +85,15 @@ public class OidcController(
|
|||||||
}
|
}
|
||||||
else // Otherwise, proceed with the login / registration flow
|
else // Otherwise, proceed with the login / registration flow
|
||||||
{
|
{
|
||||||
var nonce = Guid.NewGuid().ToString();
|
|
||||||
var state = 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);
|
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);
|
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||||
return Redirect(authUrl);
|
return Redirect(authUrl);
|
||||||
}
|
}
|
||||||
@ -70,7 +109,7 @@ public class OidcController(
|
|||||||
/// Handles Apple authentication directly from mobile apps
|
/// Handles Apple authentication directly from mobile apps
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("apple/mobile")]
|
[HttpPost("apple/mobile")]
|
||||||
public async Task<ActionResult<AuthChallenge>> AppleMobileSignIn(
|
public async Task<ActionResult<Models.AuthChallenge>> AppleMobileSignIn(
|
||||||
[FromBody] AppleMobileSignInRequest request)
|
[FromBody] AppleMobileSignInRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -100,6 +139,11 @@ public class OidcController(
|
|||||||
request.DeviceId
|
request.DeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (challenge == null)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create authentication challenge");
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(challenge);
|
return Ok(challenge);
|
||||||
}
|
}
|
||||||
catch (SecurityTokenValidationException ex)
|
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)
|
private OidcService GetOidcService(string provider)
|
||||||
{
|
{
|
||||||
return provider.ToLower() switch
|
return provider.ToLower() switch
|
||||||
{
|
{
|
||||||
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
|
"apple" => _serviceProvider.GetRequiredService<AppleOidcService>(),
|
||||||
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
|
"google" => _serviceProvider.GetRequiredService<GoogleOidcService>(),
|
||||||
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
"microsoft" => _serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
||||||
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
|
"discord" => _serviceProvider.GetRequiredService<DiscordOidcService>(),
|
||||||
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
|
"github" => _serviceProvider.GetRequiredService<GitHubOidcService>(),
|
||||||
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
|
"afdian" => _serviceProvider.GetRequiredService<AfdianOidcService>(),
|
||||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
_ => 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))
|
if (string.IsNullOrEmpty(userInfo.Email))
|
||||||
throw new ArgumentException("Email is required for account creation");
|
throw new ArgumentException("Email is required for account creation");
|
||||||
|
|
||||||
// Check if an account exists by email
|
// Find or create the account connection
|
||||||
var existingAccount = await accounts.LookupAccount(userInfo.Email);
|
var connection = await _connectionService.FindOrCreateConnection(userInfo, provider);
|
||||||
|
|
||||||
|
// If connection already has an account, return it
|
||||||
|
if (!string.IsNullOrEmpty(connection.AccountId))
|
||||||
|
{
|
||||||
|
if (Guid.TryParse(connection.AccountId, out var accountId))
|
||||||
|
{
|
||||||
|
var existingAccount = await _accountService.GetAccountByIdAsync(accountId);
|
||||||
if (existingAccount != null)
|
if (existingAccount != null)
|
||||||
{
|
{
|
||||||
// Check if this provider connection already exists
|
await _connectionService.UpdateConnection(connection, userInfo);
|
||||||
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)
|
|
||||||
{
|
|
||||||
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;
|
return existingAccount;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var connection = new AccountConnection
|
// Check if account exists by email
|
||||||
|
var account = await _accountService.FindByEmailAsync(userInfo.Email);
|
||||||
|
if (account == null)
|
||||||
{
|
{
|
||||||
AccountId = existingAccount.Id,
|
// Create new account using the account service
|
||||||
Provider = provider,
|
account = new CommonAccount
|
||||||
ProvidedIdentifier = userInfo.UserId!,
|
{
|
||||||
AccessToken = userInfo.AccessToken,
|
Id = Guid.NewGuid().ToString(),
|
||||||
RefreshToken = userInfo.RefreshToken,
|
Email = userInfo.Email,
|
||||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
Name = userInfo.Name ?? userInfo.Email,
|
||||||
Meta = userInfo.ToMetadata()
|
CreatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
};
|
};
|
||||||
|
|
||||||
await passDb.AccountConnections.AddAsync(connection);
|
// Save the new account
|
||||||
await passDb.SaveChangesAsync();
|
account = await _accountService.CreateAccountAsync(account);
|
||||||
|
|
||||||
return existingAccount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new account using the AccountService
|
// Update connection with account ID if needed
|
||||||
var newAccount = await accounts.CreateAccount(userInfo);
|
if (string.IsNullOrEmpty(connection.AccountId))
|
||||||
|
|
||||||
// Create the provider connection
|
|
||||||
var newConnection = new AccountConnection
|
|
||||||
{
|
{
|
||||||
AccountId = newAccount.Id,
|
connection.AccountId = account.Id;
|
||||||
Provider = provider,
|
await _connectionService.UpdateConnection(connection, userInfo);
|
||||||
ProvidedIdentifier = userInfo.UserId!,
|
}
|
||||||
AccessToken = userInfo.AccessToken,
|
|
||||||
RefreshToken = userInfo.RefreshToken,
|
|
||||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
|
||||||
Meta = userInfo.ToMetadata()
|
|
||||||
};
|
|
||||||
|
|
||||||
await passDb.AccountConnections.Add(newConnection);
|
return account;
|
||||||
await passDb.SaveChangesAsync();
|
|
||||||
|
|
||||||
return newAccount;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
108
DysonNetwork.Pass/Features/Auth/Services/SessionService.cs
Normal file
108
DysonNetwork.Pass/Features/Auth/Services/SessionService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
40
DysonNetwork.Pass/Models/ModelBase.cs
Normal file
40
DysonNetwork.Pass/Models/ModelBase.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -38,7 +38,8 @@ builder.Services.AddDbContext<PassDatabase>(options =>
|
|||||||
|
|
||||||
// Add custom services
|
// Add custom services
|
||||||
builder.Services.AddScoped<AccountService>();
|
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<MagicSpellService>();
|
||||||
builder.Services.AddScoped<AccountEventService>();
|
builder.Services.AddScoped<AccountEventService>();
|
||||||
builder.Services.AddScoped<AccountUsernameService>();
|
builder.Services.AddScoped<AccountUsernameService>();
|
||||||
@ -47,6 +48,9 @@ builder.Services.AddScoped<RelationshipService>();
|
|||||||
builder.Services.AddScoped<EmailService>();
|
builder.Services.AddScoped<EmailService>();
|
||||||
builder.Services.AddScoped<PermissionService>();
|
builder.Services.AddScoped<PermissionService>();
|
||||||
|
|
||||||
|
// Add authentication services
|
||||||
|
builder.Services.AddAuthServices();
|
||||||
|
|
||||||
// Add OIDC services
|
// Add OIDC services
|
||||||
builder.Services.AddScoped<OidcProviderService>();
|
builder.Services.AddScoped<OidcProviderService>();
|
||||||
builder.Services.AddScoped<AppleOidcService>();
|
builder.Services.AddScoped<AppleOidcService>();
|
||||||
|
@ -88,19 +88,13 @@
|
|||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Migrations\" />
|
<Folder Include="Migrations\" />
|
||||||
<Folder Include="Discovery\" />
|
<Folder Include="Discovery\" />
|
||||||
<Folder Include="Services\PassClient\" />
|
<Folder Include="Services\PassClient\" />
|
||||||
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
|
||||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ using DysonNetwork.Sphere.Safety;
|
|||||||
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
using tusdotnet.Stores;
|
using tusdotnet.Stores;
|
||||||
using DysonNetwork.Common.Interfaces;
|
using DysonNetwork.Common.Interfaces;
|
||||||
using DysonNetwork.Drive.Clients;
|
using DysonNetwork.Common.Clients;
|
||||||
using DysonNetwork.Sphere.Data;
|
using DysonNetwork.Sphere.Data;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
Reference in New Issue
Block a user