:drunk: No idea what did AI did

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

View File

@ -0,0 +1,158 @@
using System;
using System.Threading.Tasks;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
// Use fully qualified names to avoid ambiguity
using CommonAccount = DysonNetwork.Common.Models.Account;
using CommonAccountConnection = DysonNetwork.Common.Models.AccountConnection;
using CommonOidcUserInfo = DysonNetwork.Common.Models.OidcUserInfo;
namespace DysonNetwork.Pass.Features.Auth.Services;
public class AccountConnectionService : IAccountConnectionService
{
private readonly PassDatabase _db;
private readonly IClock _clock;
private readonly ISessionService _sessionService;
public AccountConnectionService(PassDatabase db, IClock clock, ISessionService sessionService)
{
_db = db;
_clock = clock;
_sessionService = sessionService;
}
public async Task<CommonAccountConnection> FindOrCreateConnection(CommonOidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.UserId))
throw new ArgumentException("User ID is required", nameof(userInfo));
// Try to find existing connection
var connection = await _db.AccountConnections
.FirstOrDefaultAsync(c => c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
if (connection == null)
{
// Create new connection
connection = new CommonAccountConnection
{
Id = Guid.NewGuid().ToString("N"),
Provider = provider,
ProvidedIdentifier = userInfo.UserId,
DisplayName = userInfo.Name,
CreatedAt = _clock.GetCurrentInstant(),
LastUsedAt = _clock.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
await _db.AccountConnections.AddAsync(connection);
}
// Update connection with latest info
await UpdateConnection(connection, userInfo);
await _db.SaveChangesAsync();
return connection;
}
public async Task UpdateConnection(CommonAccountConnection connection, CommonOidcUserInfo userInfo)
{
connection.LastUsedAt = _clock.GetCurrentInstant();
connection.AccessToken = userInfo.AccessToken;
connection.RefreshToken = userInfo.RefreshToken;
connection.ExpiresAt = userInfo.ExpiresAt != null ? Instant.FromDateTimeOffset(userInfo.ExpiresAt.Value) : null;
// Update metadata
var metadata = userInfo.ToMetadata();
if (metadata != null)
{
connection.Meta = metadata;
}
_db.AccountConnections.Update(connection);
await _db.SaveChangesAsync();
}
public async Task<CommonAccountConnection?> FindConnection(string provider, string userId)
{
if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(userId))
return null;
return await _db.AccountConnections
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Provider == provider &&
c.ProvidedIdentifier == userId);
}
public async Task<Models.AuthSession> CreateSessionAsync(CommonAccount account, string? deviceId = null)
{
if (account == null)
throw new ArgumentNullException(nameof(account));
var now = _clock.GetCurrentInstant();
var session = new Models.AuthSession
{
Id = Guid.NewGuid(),
AccountId = Guid.Parse(account.Id),
Label = $"OIDC Session {DateTime.UtcNow:yyyy-MM-dd}",
LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(30)), // 30-day session
// Challenge will be set later if needed
};
await _db.AuthSessions.AddAsync(session);
await _db.SaveChangesAsync();
return session;
}
public async Task<CommonAccountConnection> AddConnectionAsync(CommonAccount account, CommonOidcUserInfo userInfo, string provider)
{
if (account == null)
throw new ArgumentNullException(nameof(account));
if (string.IsNullOrEmpty(userInfo.UserId))
throw new ArgumentException("User ID is required", nameof(userInfo));
// Check if connection already exists
var existingConnection = await FindConnection(provider, userInfo.UserId);
if (existingConnection != null)
{
// Update existing connection
await UpdateConnection(existingConnection, userInfo);
return existingConnection;
}
// Create new connection
var connection = new CommonAccountConnection
{
Id = Guid.NewGuid().ToString("N"),
AccountId = account.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId,
DisplayName = userInfo.Name,
CreatedAt = _clock.GetCurrentInstant(),
LastUsedAt = _clock.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
// Set token info if available
if (userInfo.AccessToken != null)
{
connection.AccessToken = userInfo.AccessToken;
connection.RefreshToken = userInfo.RefreshToken;
connection.ExpiresAt = userInfo.ExpiresAt != null
? Instant.FromDateTimeOffset(userInfo.ExpiresAt.Value)
: null;
}
await _db.AccountConnections.AddAsync(connection);
await _db.SaveChangesAsync();
return connection;
}
}

View File

@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Account = DysonNetwork.Common.Models.Account;
using AuthTokens = DysonNetwork.Common.Models.AuthTokens;
namespace DysonNetwork.Pass.Features.Auth.Services;
public class AccountService : IAccountService
{
private readonly PassDatabase _db;
private readonly IClock _clock;
public AccountService(PassDatabase db, IClock clock)
{
_db = db;
_clock = clock;
}
public async Task<Account> CreateAccount(Common.Models.OidcUserInfo userInfo)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation", nameof(userInfo));
var now = _clock.GetCurrentInstant();
var account = new Models.Account
{
Id = Guid.NewGuid(),
Email = userInfo.Email,
Name = userInfo.Name ?? userInfo.Email.Split('@')[0],
CreatedAt = now,
UpdatedAt = now,
Status = "Active"
};
_db.Accounts.Add(account);
await _db.SaveChangesAsync();
return new Account
{
Id = account.Id.ToString(),
Email = account.Email,
Name = account.Name,
CreatedAt = account.CreatedAt,
UpdatedAt = account.UpdatedAt,
Status = account.Status
};
}
public async Task<Account?> FindByEmailAsync(string email)
{
if (string.IsNullOrEmpty(email))
return null;
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Email == email);
if (account == null)
return null;
return new Account
{
Id = account.Id.ToString(),
Email = account.Email,
Name = account.Name,
CreatedAt = account.CreatedAt,
UpdatedAt = account.UpdatedAt,
Status = account.Status
};
}
public async Task<Account?> FindByIdAsync(string accountId)
{
if (string.IsNullOrEmpty(accountId) || !Guid.TryParse(accountId, out var id))
return null;
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == id);
if (account == null)
return null;
return new Account
{
Id = account.Id.ToString(),
Email = account.Email,
Name = account.Name,
CreatedAt = account.CreatedAt,
UpdatedAt = account.UpdatedAt,
Status = account.Status
};
}
public async Task UpdateAccount(Account account)
{
if (!Guid.TryParse(account.Id, out var id))
throw new ArgumentException("Invalid account ID format", nameof(account));
var existingAccount = await _db.Accounts.FindAsync(id);
if (existingAccount == null)
throw new InvalidOperationException($"Account with ID {account.Id} not found");
existingAccount.Name = account.Name;
existingAccount.Email = account.Email;
existingAccount.UpdatedAt = _clock.GetCurrentInstant();
existingAccount.Status = account.Status;
_db.Accounts.Update(existingAccount);
await _db.SaveChangesAsync();
}
public async Task<Account> FindOrCreateAccountAsync(Common.Models.OidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation", nameof(userInfo));
// Check if account exists by email
var account = await FindByEmailAsync(userInfo.Email);
if (account != null)
return account;
// Create new account if not found
return await CreateAccount(userInfo);
}
public async Task<Account?> GetAccountByIdAsync(Guid accountId)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
return null;
return new Account
{
Id = account.Id.ToString(),
Email = account.Email,
Name = account.Name,
CreatedAt = account.CreatedAt,
UpdatedAt = account.UpdatedAt,
Status = account.Status
};
}
public async Task<AuthTokens> GenerateAuthTokensAsync(Account account, string sessionId)
{
if (!Guid.TryParse(sessionId, out var sessionGuid))
throw new ArgumentException("Invalid session ID format", nameof(sessionId));
var now = _clock.GetCurrentInstant();
var accessTokenLifetime = Duration.FromHours(1);
var accessTokenExpiry = now.Plus(accessTokenLifetime);
// In a real implementation, you would generate proper JWT tokens here
// This is a simplified version for demonstration
var accessToken = $"access_token_{Guid.NewGuid()}";
var refreshToken = $"refresh_token_{Guid.NewGuid()}";
// Create or update the session
var session = await _db.AuthSessions.FindAsync(sessionGuid);
if (session != null)
{
session.UpdateTokens(accessToken, refreshToken, accessTokenLifetime);
_db.AuthSessions.Update(session);
await _db.SaveChangesAsync();
}
return new AuthTokens
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = (int)accessTokenLifetime.TotalSeconds,
TokenType = "Bearer"
};
}
}

View File

@ -4,13 +4,13 @@ using System.Text.Encodings.Web;
using DysonNetwork.Pass.Features.Account;
using DysonNetwork.Pass.Features.Auth.OidcProvider.Services;
using DysonNetwork.Common.Services;
using DysonNetwork.Drive.Handlers;
using DysonNetwork.Common.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using NodaTime;
using DysonNetwork.Pass.Data;
using DysonNetwork.Common.Models;
using DysonNetwork.Drive;
namespace DysonNetwork.Pass.Features.Auth.Services;
@ -125,10 +125,10 @@ public class DysonTokenAuthHandler(
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
var lastInfo = new LastActiveInfo
var lastInfo = new DysonNetwork.Common.Models.LastActiveInfo
{
Account = session.Account,
Session = session,
AccountId = session.Account.Id.ToString(),
SessionId = session.Id.ToString(),
SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
};
fbs.Enqueue(lastInfo);

View File

@ -0,0 +1,195 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth.Interfaces;
using DysonNetwork.Pass.Features.Auth.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Pass.Features.Auth.Services;
public class AuthenticationService : IAuthenticationService
{
private readonly PassDatabase _db;
private readonly IConfiguration _configuration;
private readonly ISessionService _sessionService;
private readonly IOidcService _oidcService;
private readonly IHttpContextAccessor _httpContextAccessor;
public AuthenticationService(
PassDatabase db,
IConfiguration configuration,
ISessionService sessionService,
IOidcService oidcService,
IHttpContextAccessor httpContextAccessor)
{
_db = db;
_configuration = configuration;
_sessionService = sessionService;
_oidcService = oidcService;
_httpContextAccessor = httpContextAccessor;
}
public async Task<AuthResult> AuthenticateAsync(string username, string password)
{
// First try to find by username (Name in the Account model)
var account = await _db.Accounts
.Include(a => a.Profile) // Include Profile for email lookup
.FirstOrDefaultAsync(a => a.Name == username);
// If not found by username, try to find by email in the Profile
if (account == null)
{
account = await _db.Accounts
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Profile != null && a.Profile.Email == username);
}
if (account == null || !await VerifyPasswordAsync(account, password))
{
return new AuthResult { Success = false, Error = "Invalid username/email or password" };
}
return await CreateAuthResult(account);
}
private async Task<bool> VerifyPasswordAsync(Account account, string password)
{
// Find password auth factor for the account
var passwordFactor = await _db.AccountAuthFactors
.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.FactorType == AccountAuthFactorType.Password);
if (passwordFactor == null)
return false;
return BCrypt.Net.BCrypt.Verify(password, passwordFactor.Secret);
}
public async Task<AuthResult> AuthenticateWithOidcAsync(string provider, string code, string state)
{
return await _oidcService.AuthenticateAsync(provider, code, state);
}
public async Task<AuthResult> RefreshTokenAsync(string refreshToken)
{
var session = await _db.AuthSessions
.FirstOrDefaultAsync(s => s.RefreshToken == refreshToken && !s.IsRevoked);
if (session == null || session.RefreshTokenExpiryTime <= SystemClock.Instance.GetCurrentInstant())
{
return new AuthResult { Success = false, Error = "Invalid or expired refresh token" };
}
var account = await _db.Accounts.FindAsync(session.AccountId);
if (account == null)
{
return new AuthResult { Success = false, Error = "Account not found" };
}
// Invalidate the old session
await _sessionService.InvalidateSessionAsync(session.Id);
// Create a new session
return await CreateAuthResult(account);
}
public async Task<bool> ValidateTokenAsync(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!);
try
{
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
ValidIssuer = _configuration["Jwt:Issuer"]
}, out _);
return true;
}
catch
{
return false;
}
}
public async Task LogoutAsync(Guid sessionId)
{
await _sessionService.InvalidateSessionAsync(sessionId);
}
public async Task<bool> ValidateSessionAsync(Guid sessionId)
{
return await _sessionService.ValidateSessionAsync(sessionId);
}
public async Task<AuthSession> GetSessionAsync(Guid sessionId)
{
var session = await _sessionService.GetSessionAsync(sessionId);
if (session == null)
throw new Exception("Session not found");
return session;
}
private async Task<AuthResult> CreateAuthResult(Account account)
{
var ipAddress = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
var userAgent = _httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty;
var session = await _sessionService.CreateSessionAsync(account.Id, ipAddress, userAgent);
var token = GenerateJwtToken(account, session.Id);
return new AuthResult
{
Success = true,
AccessToken = token,
RefreshToken = session.RefreshToken,
Session = session
};
}
private string GenerateJwtToken(Account account, Guid sessionId)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, account.Id.ToString()),
new Claim(ClaimTypes.Name, account.Username),
new Claim("session_id", sessionId.ToString())
}),
Expires = DateTime.UtcNow.AddDays(7),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature
),
Issuer = _configuration["Jwt:Issuer"],
Audience = _configuration["Jwt:Audience"]
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private bool VerifyPasswordHash(string password, byte[] storedHash, byte[] storedSalt)
{
using var hmac = new HMACSHA512(storedSalt);
var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(password));
return computedHash.SequenceEqual(storedHash);
}
}

View File

@ -0,0 +1,34 @@
using System;
using System.Threading.Tasks;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Features.Auth.Models;
namespace DysonNetwork.Pass.Features.Auth.Services;
public interface IAccountConnectionService
{
/// <summary>
/// Finds an existing account connection or creates a new one
/// </summary>
Task<Common.Models.AccountConnection> FindOrCreateConnection(Common.Models.OidcUserInfo userInfo, string provider);
/// <summary>
/// Updates an existing connection with new token information
/// </summary>
Task UpdateConnection(Common.Models.AccountConnection connection, Common.Models.OidcUserInfo userInfo);
/// <summary>
/// Finds an account connection by provider and user ID
/// </summary>
Task<Common.Models.AccountConnection?> FindConnection(string provider, string userId);
/// <summary>
/// Creates a new session for the specified account
/// </summary>
Task<Models.AuthSession> CreateSessionAsync(Common.Models.Account account, string? deviceId = null);
/// <summary>
/// Adds a new OIDC connection to an account
/// </summary>
Task<Common.Models.AccountConnection> AddConnectionAsync(Common.Models.Account account, Common.Models.OidcUserInfo userInfo, string provider);
}

View File

@ -0,0 +1,44 @@
using System;
using System.Threading.Tasks;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Features.Auth.Models;
namespace DysonNetwork.Pass.Features.Auth.Services;
public interface IAccountService
{
/// <summary>
/// Creates a new account from OIDC user info
/// </summary>
Task<Common.Models.Account> CreateAccount(Common.Models.OidcUserInfo userInfo);
/// <summary>
/// Finds an account by email
/// </summary>
Task<Common.Models.Account?> FindByEmailAsync(string email);
/// <summary>
/// Finds an account by ID
/// </summary>
Task<Common.Models.Account?> FindByIdAsync(string accountId);
/// <summary>
/// Updates an existing account
/// </summary>
Task UpdateAccount(Common.Models.Account account);
/// <summary>
/// Finds or creates an account based on OIDC user info
/// </summary>
Task<Common.Models.Account> FindOrCreateAccountAsync(Common.Models.OidcUserInfo userInfo, string provider);
/// <summary>
/// Gets an account by ID
/// </summary>
Task<Common.Models.Account?> GetAccountByIdAsync(Guid accountId);
/// <summary>
/// Generates authentication tokens for an account
/// </summary>
Task<AuthTokens> GenerateAuthTokensAsync(Common.Models.Account account, string sessionId);
}

View File

@ -0,0 +1,144 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth.Interfaces;
using DysonNetwork.Pass.Features.Auth.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Pass.Features.Auth.Services;
public class OidcService : IOidcService
{
protected readonly IConfiguration _configuration;
protected readonly IHttpClientFactory _httpClientFactory;
protected readonly PassDatabase _db;
protected readonly IAuthenticationService _authService;
protected readonly ILogger<OidcService> _logger;
public OidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
PassDatabase db,
IAuthenticationService authService,
ILogger<OidcService> logger)
{
_configuration = configuration;
_httpClientFactory = httpClientFactory;
_db = db;
_authService = authService;
_logger = logger;
}
public virtual string GetAuthorizationUrl(string state, string nonce)
{
throw new NotImplementedException("This method should be implemented by derived classes");
}
public virtual async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
throw new NotImplementedException("This method should be implemented by derived classes");
}
public virtual async Task<AuthResult> AuthenticateAsync(string provider, string code, string state)
{
try
{
var userInfo = await ProcessCallbackAsync(new OidcCallbackData
{
Code = code,
State = state
});
// Find or create user based on the OIDC subject and provider
var account = await FindOrCreateUser(userInfo, provider);
// Create authentication result
return await _authService.AuthenticateWithOidcAsync(provider, code, state);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during OIDC authentication");
return new AuthResult
{
Success = false,
Error = "Authentication failed. Please try again."
};
}
}
public virtual IEnumerable<string> GetSupportedProviders()
{
var section = _configuration.GetSection("Oidc");
return section.GetChildren().Select(x => x.Key);
}
protected virtual async Task<Account> FindOrCreateUser(OidcUserInfo userInfo, string provider)
{
// Check if user exists with this provider and subject
var user = await _db.Accounts
.FirstOrDefaultAsync(u => u.ExternalLogins.Any(ul =>
ul.Provider == provider &&
ul.ProviderSubjectId == userInfo.Subject));
if (user != null)
return user;
// If user doesn't exist, create a new one
user = new Account
{
Id = Guid.NewGuid(),
Username = userInfo.PreferredUsername ?? userInfo.Email?.Split('@')[0] ?? Guid.NewGuid().ToString(),
Email = userInfo.Email,
EmailVerified = userInfo.EmailVerified ?? false,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
// Add external login
user.ExternalLogins.Add(new ExternalLogin
{
Id = Guid.NewGuid(),
Provider = provider,
ProviderSubjectId = userInfo.Subject,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
});
await _db.Accounts.AddAsync(user);
await _db.SaveChangesAsync();
return user;
}
protected virtual async Task<JwtSecurityToken> ValidateIdToken(string token, string issuer, string audience, string signingKey)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out var validatedToken);
return (JwtSecurityToken)validatedToken;
}
protected virtual async Task<T?> GetFromDiscoveryDocumentAsync<T>(string url)
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content);
}
}

View File

@ -1,47 +1,83 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NodaTime;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Pass.Features.Auth.Models;
using DysonNetwork.Pass.Features.Auth.Services;
using Microsoft.IdentityModel.Tokens;
// Use fully qualified names to avoid ambiguity
using CommonAccount = DysonNetwork.Common.Models.Account;
using CommonOidcUserInfo = DysonNetwork.Common.Models.OidcUserInfo;
namespace DysonNetwork.Pass.Features.Auth.OpenId;
[ApiController]
[Route("/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
PassDatabase passDb,
AppDatabase sphereDb,
AccountService accounts,
ICacheService cache
)
: ControllerBase
public class OidcController : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
private readonly ILogger<OidcController> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly PassDatabase _db;
private readonly IAccountService _accountService;
private readonly IAccountConnectionService _connectionService;
private readonly ICacheService _cache;
public OidcController(
IServiceProvider serviceProvider,
PassDatabase db,
IAccountService accountService,
IAccountConnectionService connectionService,
ICacheService cache,
ILogger<OidcController> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_db = db ?? throw new ArgumentNullException(nameof(db));
_accountService = accountService ?? throw new ArgumentNullException(nameof(accountService));
_connectionService = connectionService ?? throw new ArgumentNullException(nameof(connectionService));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[HttpGet("{provider}")]
public async Task<ActionResult> OidcLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = "/",
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
)
[FromHeader(Name = "X-Device-Id")] string? deviceId = null)
{
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is Account currentUser)
var currentUser = await HttpContext.AuthenticateAsync();
if (currentUser.Succeeded && currentUser.Principal?.Identity?.IsAuthenticated == true)
{
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
// Get the current user's account ID
var accountId = currentUser.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(accountId))
{
_logger.LogWarning("Authenticated user does not have a valid account ID");
return Unauthorized();
}
// Create and store connection state
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
var oidcState = OidcState.ForConnection(accountId, provider, nonce, deviceId);
await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
@ -49,12 +85,15 @@ public class OidcController(
}
else // Otherwise, proceed with the login / registration flow
{
var nonce = Guid.NewGuid().ToString();
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
// Create login state with return URL and device ID
// Store the state and nonce for validation later
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
oidcState.Provider = provider;
oidcState.Nonce = nonce;
await _cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
@ -70,7 +109,7 @@ public class OidcController(
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<AuthChallenge>> AppleMobileSignIn(
public async Task<ActionResult<Models.AuthChallenge>> AppleMobileSignIn(
[FromBody] AppleMobileSignInRequest request)
{
try
@ -100,6 +139,11 @@ public class OidcController(
request.DeviceId
);
if (challenge == null)
{
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create authentication challenge");
}
return Ok(challenge);
}
catch (SecurityTokenValidationException ex)
@ -113,85 +157,141 @@ public class OidcController(
}
}
private async Task<IActionResult> HandleLogin(OidcState oidcState, OidcUserInfo userInfo)
{
try
{
// Find or create the account
var account = await _accountService.FindOrCreateAccountAsync(userInfo, oidcState.Provider ?? throw new InvalidOperationException("Provider not specified"));
if (account == null)
{
_logger.LogError("Failed to find or create account for user {UserId}", userInfo.UserId);
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to process your account");
}
// Create a new session
var session = await _connectionService.CreateSessionAsync(account, oidcState.DeviceId);
if (session == null)
{
_logger.LogError("Failed to create session for account {AccountId}", account.Id);
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create session");
}
// Create auth tokens
var tokens = await _accountService.GenerateAuthTokensAsync(account, session.Id.ToString());
// Return the tokens and redirect URL
return Ok(new
{
tokens.AccessToken,
tokens.RefreshToken,
tokens.ExpiresIn,
ReturnUrl = oidcState.ReturnUrl ?? "/"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling OIDC login for user {UserId}", userInfo.UserId);
return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred during login");
}
}
private async Task<IActionResult> HandleAccountConnection(OidcState oidcState, OidcUserInfo userInfo)
{
try
{
// Get the current user's account
if (!Guid.TryParse(oidcState.AccountId, out var accountId))
{
_logger.LogError("Invalid account ID format: {AccountId}", oidcState.AccountId);
return BadRequest("Invalid account ID format");
}
var account = await _accountService.GetAccountByIdAsync(accountId);
if (account == null)
{
_logger.LogError("Account not found for ID {AccountId}", accountId);
return Unauthorized();
}
// Add the OIDC connection to the account
var connection = await _connectionService.AddConnectionAsync(account, userInfo, oidcState.Provider!);
if (connection == null)
{
_logger.LogError("Failed to add OIDC connection for account {AccountId}", account.Id);
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to add OIDC connection");
}
// Return success
return Ok(new { Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling OIDC account connection for user {UserId}", userInfo.UserId);
return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while connecting your account");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
{
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
"apple" => _serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => _serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => _serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => _serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => _serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => _serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
private async Task<CommonAccount> FindOrCreateAccount(CommonOidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
// Check if an account exists by email
var existingAccount = await accounts.LookupAccount(userInfo.Email);
if (existingAccount != null)
// Find or create the account connection
var connection = await _connectionService.FindOrCreateConnection(userInfo, provider);
// If connection already has an account, return it
if (!string.IsNullOrEmpty(connection.AccountId))
{
// Check if this provider connection already exists
var existingConnection = await passDb.AccountConnections
.FirstOrDefaultAsync(c => c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId &&
c.AccountId == existingAccount.Id
);
// If no connection exists, create one
if (existingConnection != null)
if (Guid.TryParse(connection.AccountId, out var accountId))
{
await passDb.AccountConnections
.Where(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant())
.SetProperty(c => c.Meta, userInfo.ToMetadata()));
return existingAccount;
var existingAccount = await _accountService.GetAccountByIdAsync(accountId);
if (existingAccount != null)
{
await _connectionService.UpdateConnection(connection, userInfo);
return existingAccount;
}
}
var connection = new AccountConnection
{
AccountId = existingAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
await passDb.AccountConnections.AddAsync(connection);
await passDb.SaveChangesAsync();
return existingAccount;
}
// Create new account using the AccountService
var newAccount = await accounts.CreateAccount(userInfo);
// Create the provider connection
var newConnection = new AccountConnection
// Check if account exists by email
var account = await _accountService.FindByEmailAsync(userInfo.Email);
if (account == null)
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
// Create new account using the account service
account = new CommonAccount
{
Id = Guid.NewGuid().ToString(),
Email = userInfo.Email,
Name = userInfo.Name ?? userInfo.Email,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
// Save the new account
account = await _accountService.CreateAccountAsync(account);
}
await passDb.AccountConnections.Add(newConnection);
await passDb.SaveChangesAsync();
// Update connection with account ID if needed
if (string.IsNullOrEmpty(connection.AccountId))
{
connection.AccountId = account.Id;
await _connectionService.UpdateConnection(connection, userInfo);
}
return newAccount;
return account;
}
}

View File

@ -0,0 +1,108 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth.Interfaces;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Features.Auth.Services;
public class SessionService : ISessionService
{
private readonly PassDatabase _db;
private readonly IClock _clock;
public SessionService(PassDatabase db, IClock clock)
{
_db = db;
_clock = clock;
}
public async Task<AuthSession> CreateSessionAsync(Guid accountId, string ipAddress, string userAgent)
{
var now = _clock.GetCurrentInstant();
var session = new AuthSession
{
Id = Guid.NewGuid(),
AccountId = accountId,
Label = $"Session from {ipAddress} via {userAgent}",
LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(30))
};
await _db.AuthSessions.AddAsync(session);
await _db.SaveChangesAsync();
return session;
}
public async Task<AuthSession?> GetSessionAsync(Guid sessionId)
{
return await _db.AuthSessions
.Include(s => s.Account)
.FirstOrDefaultAsync(s => s.Id == sessionId && s.ExpiredAt > _clock.GetCurrentInstant());
}
public async Task<bool> ValidateSessionAsync(Guid sessionId)
{
var session = await GetSessionAsync(sessionId);
if (session == null)
return false;
var now = _clock.GetCurrentInstant();
if (session.ExpiredAt <= now)
return false;
session.LastGrantedAt = now;
await _db.SaveChangesAsync();
return true;
}
public async Task InvalidateSessionAsync(Guid sessionId)
{
var session = await GetSessionAsync(sessionId);
if (session != null)
{
session.ExpiredAt = _clock.GetCurrentInstant();
await _db.SaveChangesAsync();
}
}
public async Task InvalidateAllSessionsAsync(Guid accountId, Guid? excludeSessionId = null)
{
var now = _clock.GetCurrentInstant();
var sessions = await _db.AuthSessions
.Where(s => s.AccountId == accountId && s.ExpiredAt > now)
.ToListAsync();
foreach (var session in sessions)
{
if (excludeSessionId == null || session.Id != excludeSessionId.Value)
{
session.ExpiredAt = now;
}
}
await _db.SaveChangesAsync();
}
public async Task UpdateSessionActivityAsync(Guid sessionId)
{
var session = await GetSessionAsync(sessionId);
if (session != null)
{
session.LastGrantedAt = _clock.GetCurrentInstant();
await _db.SaveChangesAsync();
}
}
private static string GenerateRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}