diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 3504d42..472be04 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -2,8 +2,8 @@ using System.Globalization; using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Sphere.Email; + using DysonNetwork.Sphere.Localization; -using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; @@ -376,7 +376,7 @@ public class AccountService( return; } - await mailer.SendTemplatedEmailAsync( + await mailer.SendTemplatedEmailAsync( account.Nick, contact.Content, localizer["VerificationEmail"], diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index 6efb653..a41e48b 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -5,8 +5,10 @@ using NodaTime; namespace DysonNetwork.Sphere.Auth; -public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory) +public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor) { + private HttpContext HttpContext => httpContextAccessor.HttpContext!; + /// /// Detect the risk of the current request to login /// and returns the required steps to login. @@ -63,6 +65,33 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto return totalRequiredSteps; } + public async Task CreateSessionAsync(Account.Account account, Instant time) + { + var challenge = new Challenge + { + AccountId = account.Id, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), + UserAgent = HttpContext.Request.Headers.UserAgent, + StepRemain = 1, + StepTotal = 1, + Type = ChallengeType.Oidc + }; + + var session = new Session + { + AccountId = account.Id, + CreatedAt = time, + LastGrantedAt = time, + Challenge = challenge + }; + + db.AuthChallenges.Add(challenge); + db.AuthSessions.Add(session); + await db.SaveChangesAsync(); + + return session; + } + public async Task ValidateCaptcha(string token) { if (string.IsNullOrWhiteSpace(token)) return false; diff --git a/DysonNetwork.Sphere/Auth/OpenId/AppleMobileSignInRequest.cs b/DysonNetwork.Sphere/Auth/OpenId/AppleMobileSignInRequest.cs index 238a173..0b15b0d 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/AppleMobileSignInRequest.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/AppleMobileSignInRequest.cs @@ -10,4 +10,6 @@ public class AppleMobileSignInRequest public required string IdentityToken { get; set; } [Required] public required string AuthorizationCode { get; set; } + [Required] + public required string DeviceId { get; set; } } diff --git a/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs index 7d8018f..2128291 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs @@ -140,8 +140,14 @@ public class AppleOidcService( var keyId = _configuration["Oidc:Apple:KeyId"]; var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"]; + if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) || + string.IsNullOrEmpty(privateKeyPath)) + { + throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath)."); + } + // Read the private key - var privateKey = File.ReadAllText(privateKeyPath!); + var privateKey = File.ReadAllText(privateKeyPath); // Create the JWT header var header = new Dictionary diff --git a/DysonNetwork.Sphere/Auth/OpenId/AuthCallbackController.cs b/DysonNetwork.Sphere/Auth/OpenId/AuthCallbackController.cs index c2246f2..a7c1f24 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/AuthCallbackController.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/AuthCallbackController.cs @@ -10,136 +10,6 @@ namespace DysonNetwork.Sphere.Auth.OpenId; /// [ApiController] [Route("/auth/callback")] -public class AuthCallbackController( - AppDatabase db, - AccountService accounts, - MagicSpellService spells, - AuthService auth, - IServiceProvider serviceProvider, - Account.AccountUsernameService accountUsernameService -) - : ControllerBase +public class AuthCallbackController : ControllerBase { - [HttpPost("apple")] - public async Task AppleCallbackPost( - [FromForm] string code, - [FromForm(Name = "id_token")] string idToken, - [FromForm] string? state = null, - [FromForm] string? user = null) - { - return await ProcessOidcCallback("apple", new OidcCallbackData - { - Code = code, - IdToken = idToken, - State = state, - RawData = user - }); - } - - private async Task ProcessOidcCallback(string provider, OidcCallbackData callbackData) - { - try - { - // Get the appropriate provider service - var oidcService = GetOidcService(provider); - - // Process the callback - var userInfo = await oidcService.ProcessCallbackAsync(callbackData); - - if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId)) - { - return BadRequest($"Email or user ID is missing from {provider}'s response"); - } - - // First, check if we already have a connection with this provider ID - var existingConnection = await db.AccountConnections - .Include(c => c.Account) - .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); - - if (existingConnection is not null) - return await CreateSessionAndRedirect( - oidcService, - userInfo, - existingConnection.Account, - callbackData.State - ); - - // If no existing connection, try to find an account by email - var account = await accounts.LookupAccount(userInfo.Email); - if (account == null) - { - // Create a new account using the AccountService - account = await accounts.CreateAccount(userInfo); - } - - // Create a session for the user - var session = await oidcService.CreateSessionForUserAsync(userInfo, account); - - // Generate token - var token = auth.CreateToken(session); - - // Determine where to redirect - var redirectUrl = "/"; - if (!string.IsNullOrEmpty(callbackData.State)) - { - // Use state as redirect URL (should be validated in production) - redirectUrl = callbackData.State; - } - - // Set the token as a cookie - Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddDays(30) - }); - - return Redirect(redirectUrl); - } - catch (Exception ex) - { - return BadRequest($"Error processing {provider} Sign In: {ex.Message}"); - } - } - - private OidcService GetOidcService(string provider) - { - return provider.ToLower() switch - { - "apple" => serviceProvider.GetRequiredService(), - "google" => serviceProvider.GetRequiredService(), - // Add more providers as needed - _ => throw new ArgumentException($"Unsupported provider: {provider}") - }; - } - - /// - /// Creates a session and redirects the user with a token - /// - private async Task CreateSessionAndRedirect(OidcService oidcService, OidcUserInfo userInfo, - Account.Account account, string? state) - { - // Create a session for the user - var session = await oidcService.CreateSessionForUserAsync(userInfo, account); - - // Generate token - var token = auth.CreateToken(session); - - // Determine where to redirect - var redirectUrl = "/"; - if (!string.IsNullOrEmpty(state)) - redirectUrl = state; - - // Set the token as a cookie - Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddDays(30) - }); - - return Redirect(redirectUrl); - } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs b/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs index 2526140..b784350 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs @@ -2,13 +2,20 @@ using DysonNetwork.Sphere.Account; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using NodaTime; namespace DysonNetwork.Sphere.Auth.OpenId; [ApiController] -[Route("/api/connections")] +[Route("/api/accounts/me/connections")] [Authorize] -public class ConnectionController(AppDatabase db) : ControllerBase +public class ConnectionController( + AppDatabase db, + IEnumerable oidcServices, + AccountService accountService, + AuthService authService, + IClock clock +) : ControllerBase { [HttpGet] public async Task>> GetConnections() @@ -40,4 +47,212 @@ public class ConnectionController(AppDatabase db) : ControllerBase return Ok(); } + + public class ConnectProviderRequest + { + public string Provider { get; set; } = null!; + public string? ReturnUrl { get; set; } + } + + /// + /// Initiates manual connection to an OAuth provider for the current user + /// + [HttpPost("connect")] + public async Task> InitiateConnection([FromBody] ConnectProviderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) + return Unauthorized(); + + var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(request.Provider, StringComparison.OrdinalIgnoreCase)); + if (oidcService == null) + return BadRequest($"Provider '{request.Provider}' is not supported"); + + var existingConnection = await db.AccountConnections + .AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName); + + if (existingConnection) + return BadRequest($"You already have a {request.Provider} connection"); + + var state = Guid.NewGuid().ToString("N"); + var nonce = Guid.NewGuid().ToString("N"); + HttpContext.Session.SetString($"oidc_state_{state}", $"{currentUser.Id}|{request.Provider}|{nonce}"); + + var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections"; + HttpContext.Session.SetString($"oidc_return_url_{state}", finalReturnUrl); + + var authUrl = oidcService.GetAuthorizationUrl(state, nonce); + + return Ok(new + { + authUrl, + message = $"Redirect to this URL to connect your {request.Provider} account" + }); + } + + [AllowAnonymous] + [Route("/auth/callback/{provider}")] + [HttpGet, HttpPost] + public async Task HandleCallback([FromRoute] string provider) + { + var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); + if (oidcService == null) + return BadRequest($"Provider '{provider}' is not supported."); + + var callbackData = await ExtractCallbackData(Request); + if (callbackData.State == null) + return BadRequest("State parameter is missing."); + + var sessionState = HttpContext.Session.GetString($"oidc_state_{callbackData.State!}"); + HttpContext.Session.Remove($"oidc_state_{callbackData.State}"); + + // If sessionState is present, it's a manual connection flow for an existing user. + if (sessionState != null) + { + var stateParts = sessionState.Split('|'); + if (stateParts.Length != 3 || !stateParts[1].Equals(provider, StringComparison.OrdinalIgnoreCase)) + return BadRequest("State mismatch."); + + var accountId = Guid.Parse(stateParts[0]); + return await HandleManualConnection(provider, oidcService, callbackData, accountId); + } + + // Otherwise, it's a login or registration flow. + return await HandleLoginOrRegistration(provider, oidcService, callbackData); + } + + private async Task HandleManualConnection(string provider, OidcService oidcService, OidcCallbackData callbackData, Guid accountId) + { + OidcUserInfo userInfo; + try + { + userInfo = await oidcService.ProcessCallbackAsync(callbackData); + } + catch (Exception ex) + { + return BadRequest($"Error processing callback: {ex.Message}"); + } + + var existingConnection = await db.AccountConnections + .FirstOrDefaultAsync(c => c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) && c.ProvidedIdentifier == userInfo.UserId); + + if (existingConnection != null && existingConnection.AccountId != accountId) + { + return BadRequest($"This {provider} account is already linked to another user."); + } + + var userConnection = await db.AccountConnections + .FirstOrDefaultAsync(c => c.AccountId == accountId && c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase)); + + if (userConnection != null) + { + userConnection.AccessToken = userInfo.AccessToken; + userConnection.RefreshToken = userInfo.RefreshToken; + userConnection.LastUsedAt = clock.GetCurrentInstant(); + } + else + { + db.AccountConnections.Add(new AccountConnection + { + AccountId = accountId, + Provider = provider, + ProvidedIdentifier = userInfo.UserId!, + AccessToken = userInfo.AccessToken, + RefreshToken = userInfo.RefreshToken, + LastUsedAt = clock.GetCurrentInstant() + }); + } + + await db.SaveChangesAsync(); + + var returnUrl = HttpContext.Session.GetString($"oidc_return_url_{callbackData.State}"); + HttpContext.Session.Remove($"oidc_return_url_{callbackData.State}"); + + return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); + } + + private async Task HandleLoginOrRegistration(string provider, OidcService oidcService, OidcCallbackData callbackData) + { + OidcUserInfo userInfo; + try + { + userInfo = await oidcService.ProcessCallbackAsync(callbackData); + } + catch (Exception ex) + { + return BadRequest($"Error processing callback: {ex.Message}"); + } + + if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId)) + { + return BadRequest($"Email or user ID is missing from {provider}'s response"); + } + + var connection = await db.AccountConnections + .Include(c => c.Account) + .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); + + if (connection != null) + { + // Login existing user + var session = await authService.CreateSessionAsync(connection.Account, clock.GetCurrentInstant()); + var token = authService.CreateToken(session); + return Redirect($"/?token={token}"); + } + + var account = await accountService.LookupAccount(userInfo.Email); + if (account == null) + { + // Register new user + account = await accountService.CreateAccount(userInfo); + } + + if (account == null) + { + return BadRequest("Unable to create or link account."); + } + + // Create connection for new or existing user + var newConnection = new AccountConnection + { + Account = account, + Provider = provider, + ProvidedIdentifier = userInfo.UserId!, + AccessToken = userInfo.AccessToken, + RefreshToken = userInfo.RefreshToken, + LastUsedAt = clock.GetCurrentInstant() + }; + db.AccountConnections.Add(newConnection); + + await db.SaveChangesAsync(); + + var loginSession = await authService.CreateSessionAsync(account, clock.GetCurrentInstant()); + var loginToken = authService.CreateToken(loginSession); + return Redirect($"/?token={loginToken}"); + } + + private async Task ExtractCallbackData(HttpRequest request) + { + var data = new OidcCallbackData(); + if (request.Method == "GET") + { + data.Code = request.Query["code"].FirstOrDefault() ?? ""; + data.IdToken = request.Query["id_token"].FirstOrDefault() ?? ""; + data.State = request.Query["state"].FirstOrDefault(); + } + else if (request.Method == "POST" && request.HasFormContentType) + { + var form = await request.ReadFormAsync(); + data.Code = form["code"].FirstOrDefault() ?? ""; + data.IdToken = form["id_token"].FirstOrDefault() ?? ""; + data.State = form["state"].FirstOrDefault(); + if (form.ContainsKey("user")) + { + data.RawData = form["user"].FirstOrDefault(); + } + } + + return data; + } + + } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/DiscordOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/DiscordOidcService.cs new file mode 100644 index 0000000..21a69a4 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/DiscordOidcService.cs @@ -0,0 +1,95 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace DysonNetwork.Sphere.Auth.OpenId; + +public class DiscordOidcService : OidcService +{ + public DiscordOidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) + : base(configuration, httpClientFactory, db) + { + } + + public override string ProviderName => "Discord"; + protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint + protected override string ConfigSectionName => "Discord"; + + public override string GetAuthorizationUrl(string state, string nonce) + { + var config = GetProviderConfig(); + var queryParams = new Dictionary + { + { "client_id", config.ClientId }, + { "redirect_uri", config.RedirectUri }, + { "response_type", "code" }, + { "scope", "identify email" }, + { "state", state }, + }; + + var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + return $"https://discord.com/api/oauth2/authorize?{queryString}"; + } + + public override async Task ProcessCallbackAsync(OidcCallbackData callbackData) + { + var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); + if (tokenResponse?.AccessToken == null) + { + throw new InvalidOperationException("Failed to obtain access token from Discord"); + } + + var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); + + userInfo.AccessToken = tokenResponse.AccessToken; + userInfo.RefreshToken = tokenResponse.RefreshToken; + + return userInfo; + } + + protected override async Task ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) + { + var config = GetProviderConfig(); + var client = _httpClientFactory.CreateClient(); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", config.ClientId }, + { "client_secret", config.ClientSecret }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", config.RedirectUri }, + }); + + var response = await client.PostAsync("https://discord.com/api/oauth2/token", content); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(); + } + + private async Task GetUserInfoAsync(string accessToken) + { + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); + request.Headers.Add("Authorization", $"Bearer {accessToken}"); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var discordUser = JsonDocument.Parse(json).RootElement; + + var userId = discordUser.GetProperty("id").GetString() ?? ""; + var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; + + return new OidcUserInfo + { + UserId = userId, + Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", + EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && verifiedElement.GetBoolean(), + DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement) ? globalNameElement.GetString() : null) ?? "", + PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", + ProfilePictureUrl = !string.IsNullOrEmpty(avatar) ? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png" : "", + Provider = ProviderName + }; + } +} diff --git a/DysonNetwork.Sphere/Auth/OpenId/GitHubOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/GitHubOidcService.cs new file mode 100644 index 0000000..c783c74 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/GitHubOidcService.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace DysonNetwork.Sphere.Auth.OpenId; + +public class GitHubOidcService : OidcService +{ + public GitHubOidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) + : base(configuration, httpClientFactory, db) + { + } + + public override string ProviderName => "GitHub"; + protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint + protected override string ConfigSectionName => "GitHub"; + + public override string GetAuthorizationUrl(string state, string nonce) + { + var config = GetProviderConfig(); + var queryParams = new Dictionary + { + { "client_id", config.ClientId }, + { "redirect_uri", config.RedirectUri }, + { "scope", "user:email" }, + { "state", state }, + }; + + var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + return $"https://github.com/login/oauth/authorize?{queryString}"; + } + + public override async Task ProcessCallbackAsync(OidcCallbackData callbackData) + { + var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); + if (tokenResponse?.AccessToken == null) + { + throw new InvalidOperationException("Failed to obtain access token from GitHub"); + } + + var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); + + userInfo.AccessToken = tokenResponse.AccessToken; + userInfo.RefreshToken = tokenResponse.RefreshToken; + + return userInfo; + } + + protected override async Task ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) + { + var config = GetProviderConfig(); + var client = _httpClientFactory.CreateClient(); + + var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", config.ClientId }, + { "client_secret", config.ClientSecret }, + { "code", code }, + { "redirect_uri", config.RedirectUri }, + }) + }; + tokenRequest.Headers.Add("Accept", "application/json"); + + var response = await client.SendAsync(tokenRequest); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(); + } + + private async Task GetUserInfoAsync(string accessToken) + { + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); + request.Headers.Add("Authorization", $"Bearer {accessToken}"); + request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var githubUser = JsonDocument.Parse(json).RootElement; + + var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null; + if (string.IsNullOrEmpty(email)) + { + email = await GetPrimaryEmailAsync(accessToken); + } + + return new OidcUserInfo + { + UserId = githubUser.GetProperty("id").GetInt64().ToString(), + Email = email, + DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "", + PreferredUsername = githubUser.GetProperty("login").GetString() ?? "", + ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement) ? avatarElement.GetString() ?? "" : "", + Provider = ProviderName + }; + } + + private async Task GetPrimaryEmailAsync(string accessToken) + { + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails"); + request.Headers.Add("Authorization", $"Bearer {accessToken}"); + request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + + var emails = await response.Content.ReadFromJsonAsync>(); + return emails?.FirstOrDefault(e => e.Primary)?.Email; + } + + private class GitHubEmail + { + public string Email { get; set; } = ""; + public bool Primary { get; set; } + public bool Verified { get; set; } + } +} diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs index fab345b..3553886 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs @@ -29,7 +29,7 @@ public class OidcController( var nonce = Guid.NewGuid().ToString(); // Get the authorization URL and redirect the user - var authUrl = oidcService.GetAuthorizationUrl(state, nonce); + var authUrl = oidcService.GetAuthorizationUrl(state ?? "/", nonce); return Redirect(authUrl); } catch (Exception ex) @@ -43,7 +43,8 @@ public class OidcController( /// Handles Apple authentication directly from mobile apps /// [HttpPost("apple/mobile")] - public async Task> AppleMobileSignIn([FromBody] AppleMobileSignInRequest request) + public async Task> AppleMobileSignIn( + [FromBody] AppleMobileSignInRequest request) { try { @@ -65,7 +66,12 @@ public class OidcController( var account = await FindOrCreateAccount(userInfo, "apple"); // Create session using the OIDC service - var session = await appleService.CreateSessionForUserAsync(userInfo, account); + var session = await appleService.CreateSessionForUserAsync( + userInfo, + account, + HttpContext, + request.DeviceId + ); // Generate token using existing auth service var token = authService.CreateToken(session); diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs index 4c120ad..f2f53a1 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs @@ -1,7 +1,5 @@ using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; -using System.Security.Claims; -using System.Text.Json; using System.Text.Json.Serialization; using DysonNetwork.Sphere.Account; using Microsoft.EntityFrameworkCore; @@ -15,6 +13,8 @@ namespace DysonNetwork.Sphere.Auth.OpenId; /// public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) { + protected readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + /// /// Gets the unique identifier for this provider /// @@ -67,7 +67,8 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto /// /// Exchange the authorization code for tokens /// - protected async Task ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) + protected virtual async Task ExchangeCodeForTokensAsync(string code, + string? codeVerifier = null) { var config = GetProviderConfig(); var discoveryDocument = await GetDiscoveryDocumentAsync(); @@ -160,7 +161,12 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto /// Creates a challenge and session for an authenticated user /// Also creates or updates the account connection /// - public async Task CreateSessionForUserAsync(OidcUserInfo userInfo, Account.Account account) + public async Task CreateSessionForUserAsync( + OidcUserInfo userInfo, + Account.Account account, + HttpContext request, + string deviceId + ) { // Create or update the account connection var connection = await db.AccountConnections @@ -194,7 +200,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto Platform = ChallengePlatform.Unidentified, Audiences = [ProviderName], Scopes = ["*"], - AccountId = account.Id + AccountId = account.Id, + DeviceId = deviceId, + IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null, + UserAgent = request.Request.Headers.UserAgent, }; await db.AuthChallenges.AddAsync(challenge); @@ -202,9 +211,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto // Create a session var session = new Session { - LastGrantedAt = now, AccountId = account.Id, - ChallengeId = challenge.Id, + CreatedAt = now, + LastGrantedAt = now, + Challenge = challenge }; await db.AuthSessions.AddAsync(session); diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 7d55d2b..3630bee 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Net; using System.Text.Json; using System.Threading.RateLimiting; using DysonNetwork.Sphere; @@ -83,9 +84,17 @@ builder.Services.AddSingleton(_ => var connection = builder.Configuration.GetConnectionString("FastRetrieve")!; return ConnectionMultiplexer.Connect(connection); }); +builder.Services.AddSingleton(SystemClock.Instance); +builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); + +// Register OIDC services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; @@ -113,6 +122,12 @@ builder.Services.Configure(options => // Other pipelines +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.HttpOnly = !builder.Configuration["BaseUrl"]!.StartsWith("https"); + options.Cookie.IsEssential = true; +}); builder.Services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts => { opts.Window = TimeSpan.FromMinutes(1); @@ -257,7 +272,7 @@ builder.Services.AddQuartz(q => .WithIntervalInSeconds(60) .RepeatForever()) ); - + var lastActiveFlushJob = new JobKey("LastActiveFlush"); q.AddJob(opts => opts.WithIdentity(lastActiveFlushJob)); q.AddTrigger(opts => opts @@ -267,7 +282,6 @@ builder.Services.AddQuartz(q => .WithIntervalInMinutes(5) .RepeatForever()) ); - }); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); @@ -288,11 +302,29 @@ app.UseSwaggerUI(); app.UseRequestLocalization(); -app.UseForwardedHeaders(new ForwardedHeadersOptions +// Configure forwarded headers with known proxies from configuration { - ForwardedHeaders = ForwardedHeaders.All -}); + var knownProxiesSection = builder.Configuration.GetSection("KnownProxies"); + var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; + if (knownProxiesSection.Exists()) + { + var proxyAddresses = knownProxiesSection.Get(); + if (proxyAddresses != null) + foreach (var proxy in proxyAddresses) + if (IPAddress.TryParse(proxy, out var ipAddress)) + forwardedHeadersOptions.KnownProxies.Add(ipAddress); + } + else + { + forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); + forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); + } + + app.UseForwardedHeaders(forwardedHeadersOptions); +} + +app.UseSession(); app.UseCors(opts => opts.SetIsOriginAllowed(_ => true) .WithExposedHeaders("*") diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 4e558e6..26a6a9a 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -95,5 +95,9 @@ "KeyId": "B668YP4KBG", "PrivateKeyPath": "./Keys/Solarpass.p8" } - } + }, + "KnownProxies": [ + "127.0.0.1", + "::1" + ] } \ No newline at end of file diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 4e302f1..6ea6d0c 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -41,6 +41,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded