: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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -12,11 +12,13 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.6" />
|
||||
<PackageReference Include="Minio" Version="6.0.5" />
|
||||
<PackageReference Include="NetVips" Version="3.1.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
|
||||
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
||||
@ -26,6 +28,8 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>1701;1702;1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -4,16 +4,11 @@ using Quartz;
|
||||
using DysonNetwork.Drive.Auth;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DysonNetwork.Common.Models;
|
||||
using System;
|
||||
|
||||
namespace DysonNetwork.Drive.Handlers;
|
||||
|
||||
public class LastActiveInfo
|
||||
{
|
||||
public Session Session { get; set; } = null!;
|
||||
public Account Account { get; set; } = null!;
|
||||
public Instant SeenAt { get; set; }
|
||||
}
|
||||
|
||||
public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<LastActiveInfo>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
||||
@ -23,18 +18,18 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa
|
||||
|
||||
// Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
|
||||
var distinctItems = items
|
||||
.GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id))
|
||||
.GroupBy(x => (SessionId: x.SessionId, AccountId: x.AccountId))
|
||||
.Select(g => g.OrderByDescending(x => x.SeenAt).First())
|
||||
.ToList();
|
||||
|
||||
// Build dictionaries so we can match session/account IDs to their new "last seen" timestamps
|
||||
var sessionIdMap = distinctItems
|
||||
.GroupBy(x => x.SessionId)
|
||||
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||
.ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt);
|
||||
|
||||
var accountIdMap = distinctItems
|
||||
.GroupBy(x => x.AccountId)
|
||||
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||
.ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt);
|
||||
|
||||
// Update sessions using native EF Core ExecuteUpdateAsync
|
||||
foreach (var kvp in sessionIdMap)
|
||||
|
@ -1,11 +1,17 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Pass.Features.Auth.Models;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using Account = DysonNetwork.Pass.Features.Auth.Models.Account;
|
||||
using AccountConnection = DysonNetwork.Pass.Features.Auth.Models.AccountConnection;
|
||||
using AccountAuthFactor = DysonNetwork.Pass.Features.Auth.Models.AccountAuthFactor;
|
||||
using AuthSession = DysonNetwork.Pass.Features.Auth.Models.AuthSession;
|
||||
using AuthChallenge = DysonNetwork.Pass.Features.Auth.Models.AuthChallenge;
|
||||
|
||||
namespace DysonNetwork.Pass.Data;
|
||||
|
||||
@ -19,11 +25,9 @@ public class PassDatabase(
|
||||
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
||||
|
||||
public DbSet<MagicSpell> MagicSpells { get; set; }
|
||||
public DbSet<Account> Accounts { get; set; }
|
||||
public DbSet<AccountConnection> AccountConnections { get; set; }
|
||||
public DbSet<Profile> AccountProfiles { get; set; }
|
||||
public DbSet<AccountContact> AccountContacts { get; set; }
|
||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
||||
public DbSet<Account> Accounts { get; set; } = null!;
|
||||
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
|
||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
|
||||
public DbSet<Relationship> AccountRelationships { get; set; }
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<Badge> Badges { get; set; }
|
||||
@ -77,6 +81,213 @@ public class PassDatabase(
|
||||
.WithMany(a => a.IncomingRelationships)
|
||||
.HasForeignKey(r => r.RelatedId);
|
||||
|
||||
// Configure AuthSession
|
||||
modelBuilder.Entity<AuthSession>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
entity.Property(e => e.Label)
|
||||
.HasMaxLength(500);
|
||||
|
||||
entity.Property(e => e.LastGrantedAt)
|
||||
.IsRequired();
|
||||
|
||||
entity.Property(e => e.ExpiredAt)
|
||||
.IsRequired();
|
||||
|
||||
entity.Property(e => e.AccessToken)
|
||||
.HasMaxLength(1000);
|
||||
|
||||
entity.Property(e => e.RefreshToken)
|
||||
.HasMaxLength(1000);
|
||||
|
||||
entity.Property(e => e.IpAddress)
|
||||
.HasMaxLength(128);
|
||||
|
||||
entity.Property(e => e.UserAgent)
|
||||
.HasMaxLength(500);
|
||||
|
||||
entity.HasOne(s => s.Account)
|
||||
.WithMany(a => a.Sessions)
|
||||
.HasForeignKey(s => s.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.Challenge)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ChallengeId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
// Configure AuthChallenge
|
||||
modelBuilder.Entity<AuthChallenge>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Type)
|
||||
.IsRequired()
|
||||
.HasConversion<string>();
|
||||
|
||||
entity.Property(e => e.Platform)
|
||||
.IsRequired()
|
||||
.HasConversion<string>();
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.IsRequired();
|
||||
|
||||
entity.Property(e => e.ExpiredAt);
|
||||
|
||||
entity.Property(e => e.StepRemain)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(1);
|
||||
|
||||
entity.Property(e => e.StepTotal)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(1);
|
||||
|
||||
entity.Property(e => e.FailedAttempts)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0);
|
||||
|
||||
entity.Property(e => e.IpAddress)
|
||||
.HasMaxLength(128);
|
||||
|
||||
entity.Property(e => e.UserAgent)
|
||||
.HasMaxLength(512);
|
||||
|
||||
entity.Property(e => e.DeviceId)
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.Property(e => e.Nonce)
|
||||
.HasMaxLength(1024);
|
||||
|
||||
entity.Property(e => e.BlacklistFactors)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.Audiences)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.Scopes)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.HasOne<Account>()
|
||||
.WithMany(a => a.Challenges)
|
||||
.HasForeignKey(e => e.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.Ignore(e => e.Location); // Ignore Point type as it's not directly supported by EF Core
|
||||
});
|
||||
|
||||
// Configure AccountAuthFactor
|
||||
modelBuilder.Entity<AccountAuthFactor>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
entity.Property(e => e.FactorType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>();
|
||||
|
||||
entity.Property(e => e.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
entity.Property(e => e.Description)
|
||||
.HasMaxLength(500);
|
||||
|
||||
entity.Property(e => e.Secret)
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024);
|
||||
|
||||
entity.Property(e => e.IsDefault)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(false);
|
||||
|
||||
entity.Property(e => e.IsBackup)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(false);
|
||||
|
||||
entity.Property(e => e.LastUsedAt);
|
||||
entity.Property(e => e.EnabledAt);
|
||||
entity.Property(e => e.DisabledAt);
|
||||
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.HasOne(f => f.Account)
|
||||
.WithMany(a => a.AuthFactors)
|
||||
.HasForeignKey(f => f.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Remove the incorrect relationship configuration
|
||||
// The relationship is already defined in the AuthSession configuration
|
||||
});
|
||||
|
||||
// Configure Account
|
||||
modelBuilder.Entity<Account>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Email)
|
||||
.IsRequired()
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.Property(e => e.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.Property(e => e.Status)
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.IsRequired();
|
||||
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
// Configure AccountConnection
|
||||
modelBuilder.Entity<AccountConnection>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Provider)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
entity.Property(e => e.ProviderId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.Property(e => e.DisplayName)
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.Property(e => e.AccessToken)
|
||||
.HasMaxLength(1000);
|
||||
|
||||
entity.Property(e => e.RefreshToken)
|
||||
.HasMaxLength(1000);
|
||||
|
||||
entity.Property(e => e.ExpiresAt);
|
||||
|
||||
entity.Property(e => e.ProfileData)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.HasOne<Account>()
|
||||
.WithMany(a => a.Connections)
|
||||
.HasForeignKey(e => e.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
|
@ -44,7 +44,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -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.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Common.Services;
|
||||
using DysonNetwork.Drive.Handlers;
|
||||
using DysonNetwork.Common.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Drive;
|
||||
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Auth.Services;
|
||||
|
||||
@ -125,10 +125,10 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
||||
|
||||
var lastInfo = new LastActiveInfo
|
||||
var lastInfo = new DysonNetwork.Common.Models.LastActiveInfo
|
||||
{
|
||||
Account = session.Account,
|
||||
Session = session,
|
||||
AccountId = session.Account.Id.ToString(),
|
||||
SessionId = session.Id.ToString(),
|
||||
SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
|
||||
};
|
||||
fbs.Enqueue(lastInfo);
|
||||
|
@ -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.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Pass.Data;
|
||||
using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Pass.Features.Auth.Models;
|
||||
using DysonNetwork.Pass.Features.Auth.Services;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
// Use fully qualified names to avoid ambiguity
|
||||
using CommonAccount = DysonNetwork.Common.Models.Account;
|
||||
using CommonOidcUserInfo = DysonNetwork.Common.Models.OidcUserInfo;
|
||||
|
||||
namespace DysonNetwork.Pass.Features.Auth.OpenId;
|
||||
|
||||
[ApiController]
|
||||
[Route("/auth/login")]
|
||||
public class OidcController(
|
||||
IServiceProvider serviceProvider,
|
||||
PassDatabase passDb,
|
||||
AppDatabase sphereDb,
|
||||
AccountService accounts,
|
||||
ICacheService cache
|
||||
)
|
||||
: ControllerBase
|
||||
public class OidcController : ControllerBase
|
||||
{
|
||||
private const string StateCachePrefix = "oidc-state:";
|
||||
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
||||
private readonly ILogger<OidcController> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly PassDatabase _db;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IAccountConnectionService _connectionService;
|
||||
private readonly ICacheService _cache;
|
||||
|
||||
public OidcController(
|
||||
IServiceProvider serviceProvider,
|
||||
PassDatabase db,
|
||||
IAccountService accountService,
|
||||
IAccountConnectionService connectionService,
|
||||
ICacheService cache,
|
||||
ILogger<OidcController> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_accountService = accountService ?? throw new ArgumentNullException(nameof(accountService));
|
||||
_connectionService = connectionService ?? throw new ArgumentNullException(nameof(connectionService));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[HttpGet("{provider}")]
|
||||
public async Task<ActionResult> OidcLogin(
|
||||
[FromRoute] string provider,
|
||||
[FromQuery] string? returnUrl = "/",
|
||||
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
|
||||
)
|
||||
[FromHeader(Name = "X-Device-Id")] string? deviceId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var oidcService = GetOidcService(provider);
|
||||
|
||||
// If the user is already authenticated, treat as an account connection request
|
||||
if (HttpContext.Items["CurrentUser"] is Account currentUser)
|
||||
var currentUser = await HttpContext.AuthenticateAsync();
|
||||
if (currentUser.Succeeded && currentUser.Principal?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var state = Guid.NewGuid().ToString();
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
|
||||
// Get the current user's account ID
|
||||
var accountId = currentUser.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(accountId))
|
||||
{
|
||||
_logger.LogWarning("Authenticated user does not have a valid account ID");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Create and store connection state
|
||||
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
|
||||
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||
var oidcState = OidcState.ForConnection(accountId, provider, nonce, deviceId);
|
||||
await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||
|
||||
// The state parameter sent to the provider is the GUID key for the cache.
|
||||
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||
@ -49,12 +85,15 @@ public class OidcController(
|
||||
}
|
||||
else // Otherwise, proceed with the login / registration flow
|
||||
{
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
var state = Guid.NewGuid().ToString();
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
|
||||
// Create login state with return URL and device ID
|
||||
// Store the state and nonce for validation later
|
||||
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
|
||||
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||
oidcState.Provider = provider;
|
||||
oidcState.Nonce = nonce;
|
||||
await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||
|
||||
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
@ -70,7 +109,7 @@ public class OidcController(
|
||||
/// Handles Apple authentication directly from mobile apps
|
||||
/// </summary>
|
||||
[HttpPost("apple/mobile")]
|
||||
public async Task<ActionResult<AuthChallenge>> AppleMobileSignIn(
|
||||
public async Task<ActionResult<Models.AuthChallenge>> AppleMobileSignIn(
|
||||
[FromBody] AppleMobileSignInRequest request)
|
||||
{
|
||||
try
|
||||
@ -100,6 +139,11 @@ public class OidcController(
|
||||
request.DeviceId
|
||||
);
|
||||
|
||||
if (challenge == null)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create authentication challenge");
|
||||
}
|
||||
|
||||
return Ok(challenge);
|
||||
}
|
||||
catch (SecurityTokenValidationException ex)
|
||||
@ -113,85 +157,141 @@ public class OidcController(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleLogin(OidcState oidcState, OidcUserInfo userInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find or create the account
|
||||
var account = await _accountService.FindOrCreateAccountAsync(userInfo, oidcState.Provider ?? throw new InvalidOperationException("Provider not specified"));
|
||||
if (account == null)
|
||||
{
|
||||
_logger.LogError("Failed to find or create account for user {UserId}", userInfo.UserId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to process your account");
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
var session = await _connectionService.CreateSessionAsync(account, oidcState.DeviceId);
|
||||
if (session == null)
|
||||
{
|
||||
_logger.LogError("Failed to create session for account {AccountId}", account.Id);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create session");
|
||||
}
|
||||
|
||||
// Create auth tokens
|
||||
var tokens = await _accountService.GenerateAuthTokensAsync(account, session.Id.ToString());
|
||||
|
||||
// Return the tokens and redirect URL
|
||||
return Ok(new
|
||||
{
|
||||
tokens.AccessToken,
|
||||
tokens.RefreshToken,
|
||||
tokens.ExpiresIn,
|
||||
ReturnUrl = oidcState.ReturnUrl ?? "/"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling OIDC login for user {UserId}", userInfo.UserId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred during login");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleAccountConnection(OidcState oidcState, OidcUserInfo userInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the current user's account
|
||||
if (!Guid.TryParse(oidcState.AccountId, out var accountId))
|
||||
{
|
||||
_logger.LogError("Invalid account ID format: {AccountId}", oidcState.AccountId);
|
||||
return BadRequest("Invalid account ID format");
|
||||
}
|
||||
|
||||
var account = await _accountService.GetAccountByIdAsync(accountId);
|
||||
if (account == null)
|
||||
{
|
||||
_logger.LogError("Account not found for ID {AccountId}", accountId);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Add the OIDC connection to the account
|
||||
var connection = await _connectionService.AddConnectionAsync(account, userInfo, oidcState.Provider!);
|
||||
if (connection == null)
|
||||
{
|
||||
_logger.LogError("Failed to add OIDC connection for account {AccountId}", account.Id);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to add OIDC connection");
|
||||
}
|
||||
|
||||
// Return success
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling OIDC account connection for user {UserId}", userInfo.UserId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while connecting your account");
|
||||
}
|
||||
}
|
||||
|
||||
private OidcService GetOidcService(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
|
||||
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
|
||||
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
||||
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
|
||||
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
|
||||
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
|
||||
"apple" => _serviceProvider.GetRequiredService<AppleOidcService>(),
|
||||
"google" => _serviceProvider.GetRequiredService<GoogleOidcService>(),
|
||||
"microsoft" => _serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
||||
"discord" => _serviceProvider.GetRequiredService<DiscordOidcService>(),
|
||||
"github" => _serviceProvider.GetRequiredService<GitHubOidcService>(),
|
||||
"afdian" => _serviceProvider.GetRequiredService<AfdianOidcService>(),
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
|
||||
private async Task<CommonAccount> FindOrCreateAccount(CommonOidcUserInfo userInfo, string provider)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userInfo.Email))
|
||||
throw new ArgumentException("Email is required for account creation");
|
||||
|
||||
// Check if an account exists by email
|
||||
var existingAccount = await accounts.LookupAccount(userInfo.Email);
|
||||
// Find or create the account connection
|
||||
var connection = await _connectionService.FindOrCreateConnection(userInfo, provider);
|
||||
|
||||
// If connection already has an account, return it
|
||||
if (!string.IsNullOrEmpty(connection.AccountId))
|
||||
{
|
||||
if (Guid.TryParse(connection.AccountId, out var accountId))
|
||||
{
|
||||
var existingAccount = await _accountService.GetAccountByIdAsync(accountId);
|
||||
if (existingAccount != null)
|
||||
{
|
||||
// Check if this provider connection already exists
|
||||
var existingConnection = await passDb.AccountConnections
|
||||
.FirstOrDefaultAsync(c => c.Provider == provider &&
|
||||
c.ProvidedIdentifier == userInfo.UserId &&
|
||||
c.AccountId == existingAccount.Id
|
||||
);
|
||||
|
||||
// If no connection exists, create one
|
||||
if (existingConnection != null)
|
||||
{
|
||||
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()));
|
||||
|
||||
await _connectionService.UpdateConnection(connection, userInfo);
|
||||
return existingAccount;
|
||||
}
|
||||
|
||||
var connection = new AccountConnection
|
||||
{
|
||||
AccountId = existingAccount.Id,
|
||||
Provider = provider,
|
||||
ProvidedIdentifier = userInfo.UserId!,
|
||||
AccessToken = userInfo.AccessToken,
|
||||
RefreshToken = userInfo.RefreshToken,
|
||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
Meta = userInfo.ToMetadata()
|
||||
};
|
||||
|
||||
await passDb.AccountConnections.AddAsync(connection);
|
||||
await passDb.SaveChangesAsync();
|
||||
|
||||
return existingAccount;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new account using the AccountService
|
||||
var newAccount = await accounts.CreateAccount(userInfo);
|
||||
|
||||
// Create the provider connection
|
||||
var newConnection = new AccountConnection
|
||||
// Check if account exists by email
|
||||
var account = await _accountService.FindByEmailAsync(userInfo.Email);
|
||||
if (account == null)
|
||||
{
|
||||
AccountId = newAccount.Id,
|
||||
Provider = provider,
|
||||
ProvidedIdentifier = userInfo.UserId!,
|
||||
AccessToken = userInfo.AccessToken,
|
||||
RefreshToken = userInfo.RefreshToken,
|
||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
Meta = userInfo.ToMetadata()
|
||||
// Create new account using the account service
|
||||
account = new CommonAccount
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Email = userInfo.Email,
|
||||
Name = userInfo.Name ?? userInfo.Email,
|
||||
CreatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
|
||||
await passDb.AccountConnections.Add(newConnection);
|
||||
await passDb.SaveChangesAsync();
|
||||
// Save the new account
|
||||
account = await _accountService.CreateAccountAsync(account);
|
||||
}
|
||||
|
||||
return newAccount;
|
||||
// Update connection with account ID if needed
|
||||
if (string.IsNullOrEmpty(connection.AccountId))
|
||||
{
|
||||
connection.AccountId = account.Id;
|
||||
await _connectionService.UpdateConnection(connection, userInfo);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
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
|
||||
builder.Services.AddScoped<AccountService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
// Old AuthService is being replaced with the new authentication services
|
||||
// builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<MagicSpellService>();
|
||||
builder.Services.AddScoped<AccountEventService>();
|
||||
builder.Services.AddScoped<AccountUsernameService>();
|
||||
@ -47,6 +48,9 @@ builder.Services.AddScoped<RelationshipService>();
|
||||
builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<PermissionService>();
|
||||
|
||||
// Add authentication services
|
||||
builder.Services.AddAuthServices();
|
||||
|
||||
// Add OIDC services
|
||||
builder.Services.AddScoped<OidcProviderService>();
|
||||
builder.Services.AddScoped<AppleOidcService>();
|
||||
|
@ -88,19 +88,13 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
<Folder Include="Discovery\" />
|
||||
<Folder Include="Services\PassClient\" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -28,7 +28,7 @@ using DysonNetwork.Sphere.Safety;
|
||||
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||
using tusdotnet.Stores;
|
||||
using DysonNetwork.Common.Interfaces;
|
||||
using DysonNetwork.Drive.Clients;
|
||||
using DysonNetwork.Common.Clients;
|
||||
using DysonNetwork.Sphere.Data;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
Reference in New Issue
Block a user