Manual setup account connections

🐛 Fix infinite oauth token reconnect websocket due to missing device id
🐛 Fix IP forwarded headers didn't work
This commit is contained in:
LittleSheep 2025-06-15 23:35:36 +08:00
parent 16ff5588b9
commit 44ff09c119
13 changed files with 544 additions and 153 deletions

View File

@ -2,8 +2,8 @@ using System.Globalization;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
@ -376,7 +376,7 @@ public class AccountService(
return; return;
} }
await mailer.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>( await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick, account.Nick,
contact.Content, contact.Content,
localizer["VerificationEmail"], localizer["VerificationEmail"],

View File

@ -5,8 +5,10 @@ using NodaTime;
namespace DysonNetwork.Sphere.Auth; 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!;
/// <summary> /// <summary>
/// Detect the risk of the current request to login /// Detect the risk of the current request to login
/// and returns the required steps to login. /// and returns the required steps to login.
@ -63,6 +65,33 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<Session> 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<bool> ValidateCaptcha(string token) public async Task<bool> ValidateCaptcha(string token)
{ {
if (string.IsNullOrWhiteSpace(token)) return false; if (string.IsNullOrWhiteSpace(token)) return false;

View File

@ -10,4 +10,6 @@ public class AppleMobileSignInRequest
public required string IdentityToken { get; set; } public required string IdentityToken { get; set; }
[Required] [Required]
public required string AuthorizationCode { get; set; } public required string AuthorizationCode { get; set; }
[Required]
public required string DeviceId { get; set; }
} }

View File

@ -140,8 +140,14 @@ public class AppleOidcService(
var keyId = _configuration["Oidc:Apple:KeyId"]; var keyId = _configuration["Oidc:Apple:KeyId"];
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"]; 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 // Read the private key
var privateKey = File.ReadAllText(privateKeyPath!); var privateKey = File.ReadAllText(privateKeyPath);
// Create the JWT header // Create the JWT header
var header = new Dictionary<string, object> var header = new Dictionary<string, object>

View File

@ -10,136 +10,6 @@ namespace DysonNetwork.Sphere.Auth.OpenId;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("/auth/callback")] [Route("/auth/callback")]
public class AuthCallbackController( public class AuthCallbackController : ControllerBase
AppDatabase db,
AccountService accounts,
MagicSpellService spells,
AuthService auth,
IServiceProvider serviceProvider,
Account.AccountUsernameService accountUsernameService
)
: ControllerBase
{ {
[HttpPost("apple")]
public async Task<ActionResult> 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<ActionResult> 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<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
// Add more providers as needed
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
/// <summary>
/// Creates a session and redirects the user with a token
/// </summary>
private async Task<ActionResult> 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);
}
} }

View File

@ -2,13 +2,20 @@ using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController] [ApiController]
[Route("/api/connections")] [Route("/api/accounts/me/connections")]
[Authorize] [Authorize]
public class ConnectionController(AppDatabase db) : ControllerBase public class ConnectionController(
AppDatabase db,
IEnumerable<OidcService> oidcServices,
AccountService accountService,
AuthService authService,
IClock clock
) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<List<AccountConnection>>> GetConnections() public async Task<ActionResult<List<AccountConnection>>> GetConnections()
@ -40,4 +47,212 @@ public class ConnectionController(AppDatabase db) : ControllerBase
return Ok(); return Ok();
} }
public class ConnectProviderRequest
{
public string Provider { get; set; } = null!;
public string? ReturnUrl { get; set; }
}
/// <summary>
/// Initiates manual connection to an OAuth provider for the current user
/// </summary>
[HttpPost("connect")]
public async Task<ActionResult<object>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<OidcCallbackData> 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;
}
} }

View File

@ -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<string, string>
{
{ "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<OidcUserInfo> 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<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = _httpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "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<OidcTokenResponse>();
}
private async Task<OidcUserInfo> 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
};
}
}

View File

@ -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<string, string>
{
{ "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<OidcUserInfo> 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<OidcTokenResponse?> 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<string, string>
{
{ "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<OidcTokenResponse>();
}
private async Task<OidcUserInfo> 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<string?> 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<List<GitHubEmail>>();
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; }
}
}

View File

@ -29,7 +29,7 @@ public class OidcController(
var nonce = Guid.NewGuid().ToString(); var nonce = Guid.NewGuid().ToString();
// Get the authorization URL and redirect the user // Get the authorization URL and redirect the user
var authUrl = oidcService.GetAuthorizationUrl(state, nonce); var authUrl = oidcService.GetAuthorizationUrl(state ?? "/", nonce);
return Redirect(authUrl); return Redirect(authUrl);
} }
catch (Exception ex) catch (Exception ex)
@ -43,7 +43,8 @@ public class OidcController(
/// Handles Apple authentication directly from mobile apps /// Handles Apple authentication directly from mobile apps
/// </summary> /// </summary>
[HttpPost("apple/mobile")] [HttpPost("apple/mobile")]
public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileSignIn([FromBody] AppleMobileSignInRequest request) public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileSignIn(
[FromBody] AppleMobileSignInRequest request)
{ {
try try
{ {
@ -65,7 +66,12 @@ public class OidcController(
var account = await FindOrCreateAccount(userInfo, "apple"); var account = await FindOrCreateAccount(userInfo, "apple");
// Create session using the OIDC service // 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 // Generate token using existing auth service
var token = authService.CreateToken(session); var token = authService.CreateToken(session);

View File

@ -1,7 +1,5 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -15,6 +13,8 @@ namespace DysonNetwork.Sphere.Auth.OpenId;
/// </summary> /// </summary>
public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db)
{ {
protected readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
/// <summary> /// <summary>
/// Gets the unique identifier for this provider /// Gets the unique identifier for this provider
/// </summary> /// </summary>
@ -67,7 +67,8 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
/// <summary> /// <summary>
/// Exchange the authorization code for tokens /// Exchange the authorization code for tokens
/// </summary> /// </summary>
protected async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{ {
var config = GetProviderConfig(); var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync(); var discoveryDocument = await GetDiscoveryDocumentAsync();
@ -160,7 +161,12 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
/// Creates a challenge and session for an authenticated user /// Creates a challenge and session for an authenticated user
/// Also creates or updates the account connection /// Also creates or updates the account connection
/// </summary> /// </summary>
public async Task<Session> CreateSessionForUserAsync(OidcUserInfo userInfo, Account.Account account) public async Task<Session> CreateSessionForUserAsync(
OidcUserInfo userInfo,
Account.Account account,
HttpContext request,
string deviceId
)
{ {
// Create or update the account connection // Create or update the account connection
var connection = await db.AccountConnections var connection = await db.AccountConnections
@ -194,7 +200,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
Platform = ChallengePlatform.Unidentified, Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName], Audiences = [ProviderName],
Scopes = ["*"], 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); await db.AuthChallenges.AddAsync(challenge);
@ -202,9 +211,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
// Create a session // Create a session
var session = new Session var session = new Session
{ {
LastGrantedAt = now,
AccountId = account.Id, AccountId = account.Id,
ChallengeId = challenge.Id, CreatedAt = now,
LastGrantedAt = now,
Challenge = challenge
}; };
await db.AuthSessions.AddAsync(session); await db.AuthSessions.AddAsync(session);

View File

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Sphere; using DysonNetwork.Sphere;
@ -83,9 +84,17 @@ builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
var connection = builder.Configuration.GetConnectionString("FastRetrieve")!; var connection = builder.Configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection); return ConnectionMultiplexer.Connect(connection);
}); });
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ICacheService, CacheServiceRedis>(); builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
// Register OIDC services
builder.Services.AddScoped<OidcService, GoogleOidcService>();
builder.Services.AddScoped<OidcService, AppleOidcService>();
builder.Services.AddScoped<OidcService, GitHubOidcService>();
builder.Services.AddScoped<OidcService, DiscordOidcService>();
builder.Services.AddControllers().AddJsonOptions(options => builder.Services.AddControllers().AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
@ -113,6 +122,12 @@ builder.Services.Configure<RequestLocalizationOptions>(options =>
// Other pipelines // 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 => builder.Services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{ {
opts.Window = TimeSpan.FromMinutes(1); opts.Window = TimeSpan.FromMinutes(1);
@ -257,7 +272,7 @@ builder.Services.AddQuartz(q =>
.WithIntervalInSeconds(60) .WithIntervalInSeconds(60)
.RepeatForever()) .RepeatForever())
); );
var lastActiveFlushJob = new JobKey("LastActiveFlush"); var lastActiveFlushJob = new JobKey("LastActiveFlush");
q.AddJob<LastActiveFlushJob>(opts => opts.WithIdentity(lastActiveFlushJob)); q.AddJob<LastActiveFlushJob>(opts => opts.WithIdentity(lastActiveFlushJob));
q.AddTrigger(opts => opts q.AddTrigger(opts => opts
@ -267,7 +282,6 @@ builder.Services.AddQuartz(q =>
.WithIntervalInMinutes(5) .WithIntervalInMinutes(5)
.RepeatForever()) .RepeatForever())
); );
}); });
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
@ -288,11 +302,29 @@ app.UseSwaggerUI();
app.UseRequestLocalization(); 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<string[]>();
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 => app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true) opts.SetIsOriginAllowed(_ => true)
.WithExposedHeaders("*") .WithExposedHeaders("*")

View File

@ -95,5 +95,9 @@
"KeyId": "B668YP4KBG", "KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8" "PrivateKeyPath": "./Keys/Solarpass.p8"
} }
} },
"KnownProxies": [
"127.0.0.1",
"::1"
]
} }

View File

@ -41,6 +41,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIPAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9019acc02f6742e3b19ac2eab79854df3be00_003F58_003F61b957a0_003FIPAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aa8ac544afb487082402c1fa422910f2e00_003F7f_003F8e728ed6_003FIStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aa8ac544afb487082402c1fa422910f2e00_003F7f_003F8e728ed6_003FIStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>