diff --git a/API/API.csproj b/API/API.csproj index 71e4d7ea..79724a3e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -3,6 +3,7 @@ + diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index d25c2f43..1bd71e4a 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status200OK)] public async Task ChangePassword(ChangePasswordRequest data) { - if (!HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) + if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) { return Problem(AccountError.PasswordChangeInvalidPassword); } diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs new file mode 100644 index 00000000..23d7afec --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Extensions; +using OpenShock.Common.Constants; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + /// + /// Start linking an OAuth provider to the current account. + /// + /// + /// Initiates the OAuth flow (link mode) for a given provider. + /// On success this returns a 302 Found to the provider's authorization page. + /// After consent, the OAuth middleware will call the internal callback and finally + /// redirect to /1/oauth/{provider}/handoff. + /// + /// Provider key (e.g. discord). + /// + /// Redirect to the provider authorization page. + /// Unsupported or misconfigured provider. + [HttpGet("connections/{provider}/link")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) + { + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + // Kick off provider challenge in "link" mode. + // Redirect URI is our handoff endpoint which decides next UI step. + var props = new AuthenticationProperties { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { "flow", AuthConstants.OAuthLinkFlow } } + }; + + return Challenge(props, provider); + } +} \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs new file mode 100644 index 00000000..18100bee --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Services.OAuthConnection; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + /// + /// Remove an existing OAuth connection for the current user. + /// + /// Provider key (e.g. discord). + /// + /// Connection removed. + /// No connection found for this provider. + [HttpDelete("connections/{provider}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService) + { + var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider); + + if (!deleted) + return NotFound(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs new file mode 100644 index 00000000..3cdc1b65 --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.OAuthConnection; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + /// + /// List OAuth connections linked to the current user. + /// + /// Array of connections with provider key, external id, display name and link time. + /// Returns the list of connections. + [HttpGet("connections")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ListOAuthConnections([FromServices] IOAuthConnectionService connectionService) + { + var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id); + + return connections + .Select(c => new OAuthConnectionResponse + { + ProviderKey = c.ProviderKey, + ExternalId = c.ExternalId, + DisplayName = c.DisplayName, + LinkedAt = c.CreatedAt + }) + .ToArray(); + } +} \ No newline at end of file diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index f8027a12..f6238070 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -46,8 +46,9 @@ public async Task Login( HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); return LegacyEmptyOk("Successfully logged in"); }, - notActivated => Problem(AccountError.AccountNotActivated), deactivated => Problem(AccountError.AccountDeactivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly), + notActivated => Problem(AccountError.AccountNotActivated), notFound => Problem(LoginError.InvalidCredentials) ); } diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index b426cf61..5a6edecf 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -62,8 +62,9 @@ public async Task LoginV2( HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); return Ok(LoginV2OkResponse.FromUser(ok.User)); }, - notActivated => Problem(AccountError.AccountNotActivated), deactivated => Problem(AccountError.AccountDeactivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly), + notActivated => Problem(AccountError.AccountNotActivated), notFound => Problem(LoginError.InvalidCredentials) ); } diff --git a/API/Controller/Account/Signup.cs b/API/Controller/Account/Signup.cs index 5e04868e..94e2f12e 100644 --- a/API/Controller/Account/Signup.cs +++ b/API/Controller/Account/Signup.cs @@ -27,7 +27,7 @@ public async Task SignUp([FromBody] SignUp body) var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password); return creationAction.Match( ok => LegacyEmptyOk("Successfully signed up"), - alreadyExists => Problem(SignupError.EmailAlreadyExists) + alreadyExists => Problem(SignupError.UsernameOrEmailExists) ); } } \ No newline at end of file diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 6e5ae089..f5dd0b26 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -45,7 +45,7 @@ public async Task SignUpV2( var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); return creationAction.Match( _ => Ok(), - _ => Problem(SignupError.EmailAlreadyExists) + _ => Problem(SignupError.UsernameOrEmailExists) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs new file mode 100644 index 00000000..f30d2d57 --- /dev/null +++ b/API/Controller/OAuth/Authorize.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.Common.Constants; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Start OAuth authorization for a given provider (login-or-create flow). + /// + /// + /// Initiates an OAuth challenge in "login-or-create" mode. + /// Returns 302 redirect to the provider authorization page. + /// + /// Provider key (e.g. discord). + /// Redirect to the provider authorization page. + /// Unsupported or misconfigured provider. + [EnableRateLimiting("auth")] + [HttpGet("{provider}/authorize")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthAuthorize([FromRoute] string provider) + { + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + // Kick off provider challenge in "login-or-create" mode. + var props = new AuthenticationProperties + { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } } + }; + + return Challenge(props, provider); + } +} \ No newline at end of file diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs new file mode 100644 index 00000000..27b34dc4 --- /dev/null +++ b/API/Controller/OAuth/Finalize.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.Account; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Authentication; +using OpenShock.Common.Authentication.Services; +using OpenShock.Common.Constants; +using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Problems; +using OpenShock.Common.Utils; +using System.Net.Mime; +using System.Security.Claims; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Finalize an OAuth flow by either creating a new local account or linking to the current account. + /// + /// + /// Authenticates via the temporary OAuth flow cookie (set during the provider callback). + /// - create: creates a local account, then links the external identity.
+ /// - link: requires a logged-in local user; links the external identity to that user.
+ /// No access/refresh tokens are returned. + ///
+ /// Provider key (e.g. discord). + /// Finalize request. + /// + /// + /// Finalization succeeded. + /// Flow not found, bad action, username invalid, or provider mismatch. + /// Link requested but user not authenticated. + /// External already linked to another account, or duplicate link attempt. + [EnableRateLimiting("auth")] + [HttpPost("{provider}/finalize")] + [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status401Unauthorized, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] + public async Task OAuthFinalize( + [FromRoute] string provider, + [FromBody] OAuthFinalizeRequest body, + [FromServices] IAccountService accountService, + [FromServices] IOAuthConnectionService connectionService) + { + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + var action = body.Action?.Trim().ToLowerInvariant(); + if (action is not (AuthConstants.OAuthLoginOrCreateFlow or AuthConstants.OAuthLinkFlow)) + return Problem(OAuthError.UnsupportedFlow); + + // Authenticate via the short-lived OAuth flow cookie (temp scheme) + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return Problem(OAuthError.FlowNotFound); + + // Flow must belong to the same provider we’re finalizing + var providerClaim = auth.Principal.FindFirst("provider")?.Value; + if (!string.Equals(providerClaim, provider, StringComparison.OrdinalIgnoreCase)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ProviderMismatch); + } + + // External identity basics from claims (added by your handler) + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(externalId)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowMissingData); + } + + var email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + + // If the external is already linked, don’t allow relinking in either flow. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); + + if (action == AuthConstants.OAuthLinkFlow) + { + // Linking requires an authenticated session + var userRef = HttpContext.RequestServices.GetRequiredService(); + if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0) + { + // Not a logged-in session (could be API token or anonymous) + return Problem(OAuthError.NotAuthenticatedForLink); + } + + var currentUser = HttpContext.RequestServices + .GetRequiredService>() + .CurrentClient; + + if (existing is not null) + { + // Already linked to someone, block. + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + var ok = await connectionService.TryAddConnectionAsync( + userId: currentUser.Id, + provider: provider, + providerAccountId: externalId, + providerAccountName: displayName ?? email); + + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId + }); + } + + if (action is not AuthConstants.OAuthLoginOrCreateFlow) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.UnsupportedFlow); + } + + if (existing is not null) + { + // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login). + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ConnectionAlreadyExists); + } + + // We must create a local account. Your AccountService requires a password, so: + var desiredUsername = body.Username?.Trim(); + if (string.IsNullOrEmpty(desiredUsername)) + { + // Generate a reasonable username from displayName/email/externalId + desiredUsername = GenerateUsername(displayName, email, externalId, provider); + } + + // Ensure username is available; if not, try a few suffixes + desiredUsername = await EnsureAvailableUsernameAsync(desiredUsername, accountService); + + var password = string.IsNullOrEmpty(body.Password) + ? CryptoUtils.RandomString(32) // strong random (since OAuth-only users won't use it) + : body.Password; + + var created = await accountService.CreateOAuthOnlyAccountAsync( + email: email!, + username: body.Username!, + provider: provider, + providerAccountId: externalId, + providerAccountName: displayName ?? email + ); + + + if (created.IsT1) + { + return Problem(SignupError.UsernameOrEmailExists); + } + + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + var newUser = created.AsT0.Value; + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId, + Username = newUser.Name + }); + + // ------- local helpers -------- + + static string GenerateUsername(string? name, string? mail, string externalId, string providerKey) + { + if (!string.IsNullOrWhiteSpace(name)) + return Slugify(name); + + if (!string.IsNullOrWhiteSpace(mail)) + { + var at = mail.IndexOf('@'); + if (at > 0) return Slugify(mail[..at]); + } + + return $"{providerKey}_{externalId}".ToLowerInvariant(); + } + + static string Slugify(string s) + { + var slug = new string(s.Trim() + .ToLowerInvariant() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '_') + .ToArray()); + slug = System.Text.RegularExpressions.Regex.Replace(slug, "_{2,}", "_").Trim('_'); + return string.IsNullOrEmpty(slug) ? "user" : slug; + } + + static async Task EnsureAvailableUsernameAsync(string baseName, IAccountService account) + { + var candidate = baseName; + for (var i = 0; i < 10; i++) + { + var check = await account.CheckUsernameAvailabilityAsync(candidate); + if (check.IsT0) return candidate; // Success + candidate = $"{baseName}_{CryptoUtils.RandomString(4).ToLowerInvariant()}"; + } + // last resort: include a timestamp suffix + return $"{baseName}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + } + } +} diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs new file mode 100644 index 00000000..475cd42a --- /dev/null +++ b/API/Controller/OAuth/GetData.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.Models.Response; +using OpenShock.Common.Authentication; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Retrieve short-lived OAuth handoff information for the current flow. + /// + /// + /// Returns identity details from the external provider (e.g., email, display name) along with the flow expiry. + /// This endpoint is authenticated via the temporary OAuth flow cookie and is only accessible to the user who initiated the flow. + /// + /// Provider key (e.g. discord). + /// Handoff data returned. + /// Flow not found or provider mismatch. + [ResponseCache(NoStore = true)] + [EnableRateLimiting("auth")] + [HttpGet("{provider}/data")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthGetData([FromRoute] string provider) + { + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return Problem(OAuthError.FlowNotFound); + + // Read identifiers from claims (no props.Items) + var flowIdClaim = auth.Principal.FindFirst("flow_id")?.Value; + var providerClaim = auth.Principal.FindFirst("provider")?.Value; + + if (string.IsNullOrWhiteSpace(flowIdClaim) || string.IsNullOrWhiteSpace(providerClaim)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowNotFound); + } + + // Defensive: ensure the snapshot belongs to this provider + if (providerClaim != provider) + { + // Optional: you may also delete the cookie if you consider this a poisoned flow + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ProviderMismatch); + } + + return Ok(new OAuthDataResponse + { + Provider = providerClaim, + Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, + DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, + ExpiresAt = auth.Ticket.Properties.ExpiresUtc!.Value.UtcDateTime + }); + } +} diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs new file mode 100644 index 00000000..db4867a7 --- /dev/null +++ b/API/Controller/OAuth/HandOff.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using OpenShock.API.Extensions; +using OpenShock.API.Services.Account; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Authentication; +using OpenShock.Common.Constants; +using OpenShock.Common.Errors; +using OpenShock.Common.Options; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Handoff after provider callback. Decides next step (create, link, or direct sign-in). + /// + /// + /// This endpoint is reached after the OAuth middleware processed the provider callback. + /// It reads the temp OAuth flow principal and its flow (create/link). + /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow. + /// + /// Provider key (e.g. discord). + /// + /// + /// + /// Redirect to the frontend (create/link) or home on direct sign-in. + /// Flow missing or not supported. + [EnableRateLimiting("auth")] + [HttpGet("{provider}/handoff")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthHandOff( + [FromRoute] string provider, + [FromServices] IAccountService accountService, + [FromServices] IOAuthConnectionService connectionService, + [FromServices] IOptions frontendOptions) + { + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + // Temp external principal (set by OAuth SignInScheme). + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return Problem(OAuthError.FlowNotFound); + + // Flow is stored in AuthenticationProperties by the authorize step. + if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.InternalError); + } + flow = flow.ToLowerInvariant(); + + // External subject is required to resolve/link. + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(externalId)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowMissingData); + } + + var connection = await connectionService.GetByProviderExternalIdAsync(provider, externalId); + + switch (flow) + { + case AuthConstants.OAuthLoginOrCreateFlow: + { + if (connection is not null) + { + // Already linked -> sign in and go home. + // TODO: issue your UserSessionCookie/session here for connection.UserId + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Redirect("/"); + } + + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + { + Path = $"oauth/{provider}/create" + }; + return Redirect(frontendUrl.Uri.ToString()); + } + + case AuthConstants.OAuthLinkFlow: + { + if (connection is not null) + { + // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + { + Path = $"oauth/{provider}/link" + }; + return Redirect(frontendUrl.Uri.ToString()); + } + + default: + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.UnsupportedFlow); + } + } +} \ No newline at end of file diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs new file mode 100644 index 00000000..bdffb49a --- /dev/null +++ b/API/Controller/OAuth/ListProviders.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Extensions; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Get the list of supported OAuth providers. + /// + /// + /// Returns the set of provider keys that are configured and available for use. + /// + /// Returns provider keys (e.g., discord). + [HttpGet("providers")] + public async Task ListOAuthProviders() + { + return await _schemeProvider.GetAllOAuthSchemesAsync(); + } +} diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs new file mode 100644 index 00000000..5c83982f --- /dev/null +++ b/API/Controller/OAuth/_ApiController.cs @@ -0,0 +1,29 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Services.Account; +using OpenShock.Common; + +namespace OpenShock.API.Controller.OAuth; + +/// +/// OAuth management endpoints (provider listing, authorize, data handoff). +/// +[ApiController] +[Tags("OAuth")] +[ApiVersion("1")] +[Route("/{version:apiVersion}/oauth")] +public sealed partial class OAuthController : OpenShockControllerBase +{ + private readonly IAccountService _accountService; + private readonly IAuthenticationSchemeProvider _schemeProvider; + private readonly ILogger _logger; + + public OAuthController(IAccountService accountService, IAuthenticationSchemeProvider schemeProvider, ILogger logger) + { + _accountService = accountService; + _schemeProvider = schemeProvider; + _logger = logger; + } +} \ No newline at end of file diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/Extensions/AuthenticationSchemeProviderExtensions.cs new file mode 100644 index 00000000..c10ae261 --- /dev/null +++ b/API/Extensions/AuthenticationSchemeProviderExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authentication; +using OpenShock.Common.Authentication; + +namespace OpenShock.API.Extensions; + +public static class AuthenticationSchemeProviderExtensions +{ + public static async Task GetAllOAuthSchemesAsync(this IAuthenticationSchemeProvider provider) + { + var schemes = await provider.GetAllSchemesAsync(); + + return schemes + .Select(scheme => scheme.Name) + .Where(scheme => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + .ToArray(); + } + public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme) + { + if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + return false; + + var schemes = await provider.GetAllSchemesAsync(); + + return schemes.Any(s => s.Name == scheme); + } +} diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs new file mode 100644 index 00000000..a3da8b2a --- /dev/null +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class OAuthFinalizeRequest +{ + /// Action to perform: "create" or "link". + [Required] + public required string Action { get; init; } + + /// Desired username (create only). If omitted, a name will be generated from the external profile. + public string? Username { get; init; } + + /// + /// New account password (create only). If omitted, a strong random password will be generated. + /// Your current AccountService requires a password. + /// + public string? Password { get; init; } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthConnectionResponse.cs b/API/Models/Response/OAuthConnectionResponse.cs new file mode 100644 index 00000000..313b1bdc --- /dev/null +++ b/API/Models/Response/OAuthConnectionResponse.cs @@ -0,0 +1,9 @@ +namespace OpenShock.API.Models.Response; + +public sealed class OAuthConnectionResponse +{ + public required string ProviderKey { get; init; } + public required string ExternalId { get; init; } + public required string? DisplayName { get; init; } + public required DateTime LinkedAt { get; init; } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthDataResponse.cs b/API/Models/Response/OAuthDataResponse.cs new file mode 100644 index 00000000..7f4ade5b --- /dev/null +++ b/API/Models/Response/OAuthDataResponse.cs @@ -0,0 +1,10 @@ +namespace OpenShock.API.Models.Response; + +// what we return to frontend at /oauth/discord/data +public sealed class OAuthDataResponse +{ + public required string Provider { get; init; } + public required string? Email { get; init; } + public required string? DisplayName { get; init; } + public required DateTime ExpiresAt { get; init; } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthFinalizeResponse.cs b/API/Models/Response/OAuthFinalizeResponse.cs new file mode 100644 index 00000000..25f57840 --- /dev/null +++ b/API/Models/Response/OAuthFinalizeResponse.cs @@ -0,0 +1,16 @@ +namespace OpenShock.API.Models.Response; + +public sealed class OAuthFinalizeResponse +{ + /// "ok" on success; otherwise not returned (problem details emitted). + public string Status { get; init; } = "ok"; + + /// The provider key that was processed. + public required string Provider { get; init; } + + /// The external account id that was linked. + public required string ExternalId { get; init; } + + /// When action=create, the username of the newly created account. + public string? Username { get; init; } +} \ No newline at end of file diff --git a/API/Options/OAuth/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs new file mode 100644 index 00000000..00bc7a9d --- /dev/null +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -0,0 +1,14 @@ + + +namespace OpenShock.API.Options.OAuth; + +public sealed class DiscordOAuthOptions +{ + public const string SectionName = "OpenShock:OAuth2:Discord"; + + public required string ClientId { get; init; } + public required string ClientSecret { get; init; } + public required PathString CallbackPath { get; init; } + public required PathString AccessDeniedPath { get; init; } + public required string[] Scopes { get; init; } +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 3684799f..397bcd6c 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,11 +1,15 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; +using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; +using OpenShock.API.Services.OAuthConnection; using OpenShock.API.Services.UserService; using OpenShock.Common; +using OpenShock.Common.Authentication; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; @@ -35,7 +39,33 @@ builder.Services.AddOpenShockMemDB(redisConfig); builder.Services.AddOpenShockDB(databaseConfig); -builder.Services.AddOpenShockServices(); +builder.Services.AddOpenShockServices(auth => auth + .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => + { + o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName; + o.ExpireTimeSpan = TimeSpan.FromMinutes(10); + o.SlidingExpiration = false; + }) + .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => + { + o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme; + + var options = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; + + o.ClientId = options.ClientId; + o.ClientSecret = options.ClientSecret; + o.CallbackPath = options.CallbackPath; + o.AccessDeniedPath = options.AccessDeniedPath; + foreach (var scope in options.Scopes) o.Scope.Add(scope); + + o.Prompt = "none"; + o.SaveTokens = true; + + o.ClaimActions.MapJsonKey("email-verified", "verified"); + + o.Validate(); + }) +); builder.Services.AddSignalR() .AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; }) @@ -50,6 +80,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 9bd816f9..2ec98d47 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,6 +1,7 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Npgsql; using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; @@ -122,6 +123,70 @@ await _emailService.VerifyEmail(new Contact(email, username), return new Success(user); } + public async Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync( + string email, + string username, + string provider, + string providerAccountId, + string? providerAccountName) + { + email = email.ToLowerInvariant(); + provider = provider.ToLowerInvariant(); + + // Reuse your existing guards + if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email)) + return new AccountWithEmailOrUsernameExists(); + + // Fast uniqueness check (optimistic; race handled by unique constraints below) + var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username); + if (exists) return new AccountWithEmailOrUsernameExists(); + + await using var tx = await _db.Database.BeginTransactionAsync(); + + try + { + var user = new User + { + Id = Guid.CreateVersion7(), + Name = username, + Email = email, + PasswordHash = null, // OAuth-only account + ActivatedAt = DateTime.UtcNow // no activation flow + }; + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + // Link external identity + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = user.Id, + ProviderKey = provider, + ExternalId = providerAccountId, + DisplayName = providerAccountName + }); + + await _db.SaveChangesAsync(); + + await tx.CommitAsync(); + + // Ensure ActivatedAt <= CreatedAt (optional monotonic tidy-up) + if (user.CreatedAt > user.ActivatedAt) + { + user.ActivatedAt = user.CreatedAt; + await _db.SaveChangesAsync(); + } + + return new Success(user); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + // Unique constraint hit: either username/email already exists, or (provider, externalId) is already linked. + await tx.RollbackAsync(); + return new AccountWithEmailOrUsernameExists(); + } + } + public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(secret); @@ -250,7 +315,7 @@ public async Task - public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, + public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); @@ -263,14 +328,18 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc private async Task CheckPassword(string password, User user) { + if (string.IsNullOrEmpty(user.PasswordHash)) + { + return false; + } + var result = HashingUtils.VerifyPassword(password, user.PasswordHash); if (!result.Verified) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 52e74fa1..3c9b66f1 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -28,6 +28,19 @@ public interface IAccountService /// public Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password); + /// + /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. + /// The new user is activated immediately (no activation flow). Returns a conflict-style result if the + /// username/email is taken or the external identity is already linked. + /// + /// Email to set on the user. + /// Desired unique username. + /// e.g. "discord" + /// external subject/id from provider + /// display name from provider + /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked. + Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName); + /// /// /// @@ -50,7 +63,7 @@ public interface IAccountService /// /// /// - public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); + public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); /// /// Check if a password reset request exists and the secret is valid @@ -113,8 +126,9 @@ public interface IAccountService } public sealed record CreateUserLoginSessionSuccess(User User, string Token); -public readonly record struct AccountNotActivated; -public readonly record struct AccountDeactivated; +public readonly struct AccountIsOAuthOnly; +public readonly struct AccountNotActivated; +public readonly struct AccountDeactivated; public readonly struct AccountWithEmailOrUsernameExists; public readonly struct CannotDeactivatePrivilegedAccount; public readonly struct AccountDeactivationAlreadyInProgress; diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs new file mode 100644 index 00000000..2706a479 --- /dev/null +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -0,0 +1,15 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.OAuthConnection; + +/// +/// Manages external OAuth connections for users. +/// +public interface IOAuthConnectionService +{ + Task GetConnectionsAsync(Guid userId); + Task GetByProviderExternalIdAsync(string provider, string providerAccountId); + Task HasConnectionAsync(Guid userId, string provider); + Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName); + Task TryRemoveConnectionAsync(Guid userId, string provider); +} \ No newline at end of file diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs new file mode 100644 index 00000000..572e88f7 --- /dev/null +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.Account; + +public sealed class OAuthConnectionService : IOAuthConnectionService +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public OAuthConnectionService(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task GetConnectionsAsync(Guid userId) + { + return await _db.UserOAuthConnections + .AsNoTracking() + .Where(c => c.UserId == userId) + .ToArrayAsync(); + } + + public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections + .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId); + } + + public async Task HasConnectionAsync(Guid userId, string provider) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p); + } + + public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) + { + try + { + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = userId, + ProviderKey = provider.ToLowerInvariant(), + ExternalId = providerAccountId, + DisplayName = providerAccountName + }); + await _db.SaveChangesAsync(); + return true; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + // Unique constraint violation (duplicate link) + _logger.LogDebug(ex, "Duplicate OAuth link for {Provider}:{ExternalId}", provider, providerAccountId); + return false; + } + } + + public async Task TryRemoveConnectionAsync(Guid userId, string provider) + { + var p = provider.ToLowerInvariant(); + var nDeleted = await _db.UserOAuthConnections + .Where(c => c.UserId == userId && c.ProviderKey == p) + .ExecuteDeleteAsync(); + + return nDeleted > 0; + } +} diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index 9ac2bfd0..0d4d8a95 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -66,7 +66,7 @@ protected override async Task HandleAuthenticateAsync() List claims = new List(3 + tokenDto.Permissions.Count) { - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.ApiToken), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new(ClaimTypes.NameIdentifier, tokenDto.User.Id.ToString()), new(OpenShockAuthClaims.ApiTokenId, tokenDto.Id.ToString()) }; @@ -78,8 +78,6 @@ protected override async Task HandleAuthenticateAsync() var ident = new ClaimsIdentity(claims, nameof(ApiTokenAuthentication)); - Context.User = new ClaimsPrincipal(ident); - var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs index 65f4557c..19801918 100644 --- a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs @@ -60,11 +60,13 @@ protected override async Task HandleAuthenticateAsync() _authService.CurrentClient = device; Claim[] claims = [ - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.HubToken), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new Claim(ClaimTypes.NameIdentifier, device.OwnerId.ToString()), new Claim(OpenShockAuthClaims.HubId, _authService.CurrentClient.Id.ToString()), ]; + var ident = new ClaimsIdentity(claims, nameof(HubAuthentication)); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index 83918108..511f5b7d 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -81,16 +81,14 @@ protected override async Task HandleAuthenticateAsync() _userReferenceService.AuthReference = session; List claims = [ - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.UserSessionCookie), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new(ClaimTypes.NameIdentifier, retrievedUser.Id.ToString()), ]; claims.AddRange(retrievedUser.Roles.Select(r => new Claim(ClaimTypes.Role, r.ToString()))); var ident = new ClaimsIdentity(claims, nameof(UserSessionAuthentication)); - - Context.User = new ClaimsPrincipal(ident); - + var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index 7d3c8792..27ebe9b4 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -6,5 +6,10 @@ public static class OpenShockAuthSchemes public const string ApiToken = "ApiToken"; public const string HubToken = "HubToken"; + public const string OAuthFlowScheme = "OAuthFlowCookie"; + public const string OAuthFlowCookieName = ".OpenShock.OAuthFlow"; + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; + public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; } \ No newline at end of file diff --git a/Common/Common.csproj b/Common/Common.csproj index 0c57f624..a5cc32c4 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..5d0e2651 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -7,6 +7,9 @@ public static class AuthConstants public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + public const string OAuthLoginOrCreateFlow = "login-or-create"; + public const string OAuthLinkFlow = "link"; + public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index aca57860..03429815 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -27,4 +27,6 @@ public static class AccountError public static OpenShockProblem AccountNotActivated => new OpenShockProblem("Account.AccountNotActivated", "Your account has not been activated", HttpStatusCode.Unauthorized); public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized); + + public static OpenShockProblem AccountOAuthOnly => new OpenShockProblem("Account.OAuthOnly", "This account is only accessible via OAuth", HttpStatusCode.Unauthorized); } \ No newline at end of file diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs new file mode 100644 index 00000000..a48f9b3f --- /dev/null +++ b/Common/Errors/OAuthError.cs @@ -0,0 +1,54 @@ +using OpenShock.Common.Problems; +using System.Net; + +public static class OAuthError +{ + // Provider-related + public static OpenShockProblem UnsupportedProvider => new( + "OAuth.Provider.Unsupported", + "The requested OAuth provider is not supported", + HttpStatusCode.BadRequest); + + public static OpenShockProblem ProviderMismatch => new( + "OAuth.Provider.Mismatch", + "The current OAuth flow does not match the requested provider", + HttpStatusCode.BadRequest); + + // Flow-related + public static OpenShockProblem UnsupportedFlow => new( + "OAuth.Flow.Unsupported", + "This OAuth flow type is not recognized or allowed", + HttpStatusCode.Forbidden); + + public static OpenShockProblem FlowNotFound => new( + "OAuth.Flow.NotFound", + "The OAuth flow was not found, has expired, or is invalid", + HttpStatusCode.BadRequest); + + public static OpenShockProblem FlowMissingData => new( + "OAuth.Flow.MissingData", + "The OAuth provider did not supply the expected identity data", + HttpStatusCode.BadGateway); // 502 makes sense if external didn't return what we expect + + // Connection-related + public static OpenShockProblem ConnectionAlreadyExists => new( + "OAuth.Connection.AlreadyExists", + "Your account already has an OAuth connection for this provider", + HttpStatusCode.Conflict); + + public static OpenShockProblem ExternalAlreadyLinked => new( + "OAuth.Connection.AlreadyLinked", + "This external account is already linked to another user", + HttpStatusCode.Conflict); + + public static OpenShockProblem NotAuthenticatedForLink => new( + "OAuth.Link.NotAuthenticated", + "You must be signed in to link an external account", + HttpStatusCode.Unauthorized); + + // Misc / generic + public static OpenShockProblem InternalError => new( + "OAuth.InternalError", + "An unexpected error occurred while processing the OAuth flow", + HttpStatusCode.InternalServerError); +} diff --git a/Common/Errors/SignupError.cs b/Common/Errors/SignupError.cs index 8e8841b1..66535001 100644 --- a/Common/Errors/SignupError.cs +++ b/Common/Errors/SignupError.cs @@ -5,5 +5,8 @@ namespace OpenShock.Common.Errors; public static class SignupError { - public static OpenShockProblem EmailAlreadyExists => new("Signup.EmailOrUsernameAlreadyExists", "Email or username already exists", HttpStatusCode.Conflict); + public static OpenShockProblem UsernameOrEmailExists => new( + "Signup.UsernameOrEmailExists", + "The chosen username or email is already in use", + HttpStatusCode.Conflict); } \ No newline at end of file diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs new file mode 100644 index 00000000..64b05549 --- /dev/null +++ b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs @@ -0,0 +1,1464 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20250903235304_AddOAuthSupport")] + partial class AddOAuthSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used") + .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.cs b/Common/Migrations/20250903235304_AddOAuthSupport.cs new file mode 100644 index 00000000..632165bc --- /dev/null +++ b/Common/Migrations/20250903235304_AddOAuthSupport.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddOAuthSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "password_hash", + table: "users", + type: "character varying(100)", + maxLength: 100, + nullable: true, + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldCollation: "C"); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "user_oauth_connections", + columns: table => new + { + provider_key = table.Column(type: "text", nullable: false, collation: "C"), + external_id = table.Column(type: "text", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + display_name = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("user_oauth_connections_pkey", x => new { x.provider_key, x.external_id }); + table.ForeignKey( + name: "fk_user_oauth_connections_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_oauth_connections_user_id", + table: "user_oauth_connections", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + + migrationBuilder.DropTable( + name: "user_oauth_connections"); + + migrationBuilder.AlterColumn( + name: "password_hash", + table: "users", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: "", + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldNullable: true, + oldCollation: "C"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index c91a9061..645c590e 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,7 +21,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("ProductVersion", "9.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); @@ -34,6 +34,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => { b.Property("ActivatedAt") @@ -680,7 +699,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .UseCollation("ndcoll"); b.Property("PasswordHash") - .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)") .HasColumnName("password_hash") @@ -891,6 +909,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_name_changes", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => { b.Property("Id") @@ -1258,6 +1309,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "User") @@ -1371,6 +1434,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("NameChanges"); + b.Navigation("OAuthConnections"); + b.Navigation("OutgoingUserShareInvites"); b.Navigation("OwnedPublicShares"); diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 8d4a433c..16d95a55 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; @@ -46,7 +47,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) /// /// Main OpenShock DB Context /// -public class OpenShockContext : DbContext +public class OpenShockContext : DbContext, IDataProtectionKeyContext { public OpenShockContext() { @@ -105,6 +106,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet PublicShareShockerMappings { get; set; } public DbSet Users { get; set; } + + public DbSet UserOAuthConnections { get; set; } public DbSet UserActivationRequests { get; set; } @@ -123,6 +126,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } + + public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -611,6 +616,32 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("activated_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.ProviderKey, e.ExternalId }).HasName("user_oauth_connections_pkey"); + + entity.HasIndex(e => e.UserId); + + entity.ToTable("user_oauth_connections"); + + entity.Property(e => e.UserId) + .HasColumnName("user_id"); + entity.Property(e => e.ProviderKey) + .UseCollation("C") + .HasColumnName("provider_key"); + entity.Property(e => e.ExternalId) + .HasColumnName("external_id"); + entity.Property(e => e.DisplayName) + .HasColumnName("display_name"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + + entity.HasOne(c => c.User).WithMany(u => u.OAuthConnections) + .HasForeignKey(d => d.UserId) + .HasConstraintName("fk_user_oauth_connections_user_id"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.UserId).HasName("user_activation_requests_pkey"); diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 6d120fc9..9594ecfc 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -10,7 +10,7 @@ public sealed class User public required string Email { get; set; } - public required string PasswordHash { get; set; } + public string? PasswordHash { get; set; } public List Roles { get; set; } = []; @@ -20,6 +20,7 @@ public sealed class User // Navigations public UserActivationRequest? UserActivationRequest { get; set; } public UserDeactivation? UserDeactivation { get; set; } + public ICollection OAuthConnections { get; set; } = []; public ICollection ApiTokens { get; } = []; public ICollection ReportedApiTokens { get; } = []; public ICollection Devices { get; } = []; diff --git a/Common/OpenShockDb/UserOAuthConnection.cs b/Common/OpenShockDb/UserOAuthConnection.cs new file mode 100644 index 00000000..a20091a9 --- /dev/null +++ b/Common/OpenShockDb/UserOAuthConnection.cs @@ -0,0 +1,17 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class UserOAuthConnection +{ + public required Guid UserId { get; set; } + + public required string ProviderKey { get; set; } + + public required string ExternalId { get; set; } + + public required string? DisplayName { get; set; } + + public DateTime CreatedAt { get; set; } + + // Navigations + public User User { get; set; } = null!; +} diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index a09153d0..cb41118f 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Asp.Versioning; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -106,8 +107,9 @@ public static IServiceCollection AddOpenShockDB(this IServiceCollection services /// Register all OpenShock services for PRODUCTION use /// /// + /// /// - public static IServiceCollection AddOpenShockServices(this IServiceCollection services) + public static IServiceCollection AddOpenShockServices(this IServiceCollection services, Action? configureAuth = null) { // <---- ASP.NET ----> services.AddExceptionHandler(); @@ -127,15 +129,18 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped, ClientAuthService>(); services.AddScoped(); + services.AddDataProtection().PersistKeysToDbContext(); services.AddAuthenticationCore(); - new AuthenticationBuilder(services) + var authBuilder = new AuthenticationBuilder(services) .AddScheme( OpenShockAuthSchemes.UserSessionCookie, _ => { }) .AddScheme( OpenShockAuthSchemes.ApiToken, _ => { }) .AddScheme( OpenShockAuthSchemes.HubToken, _ => { }); - + + configureAuth?.Invoke(authBuilder); + services.AddAuthorization(options => { options.AddPolicy(OpenShockAuthPolicies.RankAdmin, policy => policy.RequireRole("Admin", "System"));