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 622427f4..7c448501 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([FromBody] 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..f8525d7f --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Problems; +using System.Net.Mime; +using OpenShock.API.OAuth; + +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); + + return OAuthUtil.StartOAuth(provider, OAuthFlow.Link); + } +} \ 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..8e4be621 --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -0,0 +1,28 @@ +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, CancellationToken cancellationToken) + { + var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken); + + 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..3644fabe --- /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, CancellationToken cancellationToken) + { + var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id, cancellationToken); + + 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 156baee8..b02bafa6 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -10,6 +10,7 @@ using OpenShock.Common.Utils; using System.Net.Mime; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -28,27 +29,23 @@ public sealed partial class AccountController [MapToApiVersion("1")] public async Task Login( [FromBody] Login body, - [FromServices] FrontendOptions options, CancellationToken cancellationToken) { - var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + var cookieDomain = GetCurrentCookieDomain(); + if (cookieDomain is null) return Problem(LoginError.InvalidDomain); - var loginAction = await _accountService.CreateUserLoginSessionAsync(body.Email, body.Password, new LoginContext + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken); + if (!getAccountResult.TryPickT0(out var account, out var errors)) { - Ip = HttpContext.GetRemoteIP().ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); + return errors.Match( + notFound => Problem(LoginError.InvalidCredentials), + deactivated => Problem(AccountError.AccountDeactivated), + notActivated => Problem(AccountError.AccountNotActivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly) + ); + } - return loginAction.Match( - ok => - { - HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse); - return LegacyEmptyOk("Successfully logged in"); - }, - notActivated => Problem(AccountError.AccountNotActivated), - deactivated => Problem(AccountError.AccountDeactivated), - notFound => Problem(LoginError.InvalidCredentials) - ); + await CreateSession(account.Id, cookieDomain); + return LegacyEmptyOk("Successfully logged in"); } } \ No newline at end of file diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index f0cdb503..cc7d89a9 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Mime; using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; @@ -14,6 +15,7 @@ using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Options; +using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -33,39 +35,35 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] FrontendOptions options, CancellationToken cancellationToken) { - var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + var cookieDomain = GetCurrentCookieDomain(); + if (cookieDomain is null) return Problem(LoginError.InvalidDomain); - var remoteIP = HttpContext.GetRemoteIP(); + var remoteIp = HttpContext.GetRemoteIP(); - var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIP, cancellationToken); - if (!turnStile.IsT0) + var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken); + if (!turnStile.TryPickT0(out _, out var cfErrors)) { - var cfErrors = turnStile.AsT1.Value; - if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse)) + if (cfErrors.Value.All(err => err == CloudflareTurnstileError.InvalidResponse)) return Problem(TurnstileError.InvalidTurnstile); return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - - var loginAction = await _accountService.CreateUserLoginSessionAsync(body.UsernameOrEmail, body.Password, new LoginContext + + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); + if (!getAccountResult.TryPickT0(out var account, out var errors)) { - Ip = remoteIP.ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); - - return loginAction.Match( - ok => - { - HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse); - return Ok(LoginV2OkResponse.FromUser(ok.User)); - }, - notActivated => Problem(AccountError.AccountNotActivated), - deactivated => Problem(AccountError.AccountDeactivated), - notFound => Problem(LoginError.InvalidCredentials) - ); + return errors.Match( + notFound => Problem(LoginError.InvalidCredentials), + deactivated => Problem(AccountError.AccountDeactivated), + notActivated => Problem(AccountError.AccountNotActivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly) + ); + } + + await CreateSession(account.Id, cookieDomain); + + return Ok(LoginV2OkResponse.FromUser(account)); } } \ No newline at end of file diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index 89f16111..b27e0efe 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -12,9 +12,7 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout( - [FromServices] ISessionService sessionService, - [FromServices] FrontendOptions options) + public async Task Logout([FromServices] ISessionService sessionService) { // Remove session if valid if (HttpContext.TryGetUserSessionToken(out var sessionToken)) @@ -23,18 +21,7 @@ public async Task Logout( } // Make sure cookie is removed, no matter if authenticated or not - var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is not null) - { - HttpContext.RemoveSessionKeyCookie(cookieDomainToUse); - } - else // Fallback to all domains - { - foreach (var domain in options.CookieDomains) - { - HttpContext.RemoveSessionKeyCookie(domain); - } - } + RemoveSessionKeyCookie(); // its always a success, logout endpoints should be idempotent return Ok(); 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 703f4f28..ab5d06bb 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -46,7 +46,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..d9e1d6cf --- /dev/null +++ b/API/Controller/OAuth/Authorize.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Problems; +using System.Net.Mime; +using OpenShock.API.OAuth; +using OpenShock.Common.Utils; + +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); + + if (User.HasOpenShockUserIdentity()) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + return OAuthUtil.StartOAuth(provider, OAuthFlow.LoginOrCreate); + } +} \ No newline at end of file diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs new file mode 100644 index 00000000..0e2cb322 --- /dev/null +++ b/API/Controller/OAuth/HandOff.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Options; +using OpenShock.Common.Problems; +using System.Net.Mime; +using OpenShock.API.OAuth; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Handoff after provider callback. Decides next step (create, link, or direct sign-in). + /// + /// + /// Reads the temp OAuth flow principal (flow cookie set by middleware). + /// If a matching connection exists -> signs in and redirects home. + /// Otherwise -> redirects frontend to continue the chosen flow. + /// + [EnableRateLimiting("auth")] + [HttpGet("{provider}/handoff")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthHandOff( + [FromRoute] string provider, + [FromServices] IOAuthConnectionService connectionService, + [FromServices] FrontendOptions frontendOptions, + CancellationToken cancellationToken) + { + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) + { + return error switch + { + OAuthValidationError.FlowStateMissing => RedirectFrontendError("OAuthFlowNotStarted"), + _ => RedirectFrontendError("InternalError") + }; + } + + // 1) Defense-in-depth: ensure the flow’s provider matches the route + if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("providerMismatch"); + } + + var connection = await connectionService + .GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken); + + switch (auth.Flow) + { + case OAuthFlow.LoginOrCreate: + { + if (User.HasOpenShockUserIdentity()) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("mustBeAnonymous"); + } + + if (connection is null) + { + // No connection -> continue to CREATE flow on frontend + return RedirectFrontendPath($"/oauth/{Uri.EscapeDataString(provider)}/create"); + } + + // Direct sign-in + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("internalError"); + } + + await CreateSession(connection.UserId, domain); + + // Flow cookie no longer needed + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + return RedirectFrontendPath("/home"); + } + + case OAuthFlow.Link: + { + if (!User.TryGetAuthenticatedOpenShockUserId(out var userId)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("mustBeAuthenticated"); + } + + if (connection is not null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount"); + } + + var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, cancellationToken); + if (!ok) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendConnections("linkFailed"); + } + + // Direct sign-in + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendConnections("internalError"); + } + + await CreateSession(userId, domain); + + // Flow cookie no longer needed + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + return RedirectFrontendConnections("linked"); + } + + default: + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("internalError"); + } + + // --- helpers --- + + RedirectResult RedirectFrontendPath(string path) + { + return Redirect(new Uri(frontendOptions.BaseUrl, path).ToString()); + } + + RedirectResult RedirectFrontendError(string errorType) + => RedirectFrontendPath($"/oauth/error?error={Uri.EscapeDataString(errorType)}"); + + RedirectResult RedirectFrontendConnections(string statusType) + => RedirectFrontendPath($"/settings/connections?status={Uri.EscapeDataString(statusType)}"); + } +} diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs new file mode 100644 index 00000000..be0631cf --- /dev/null +++ b/API/Controller/OAuth/ListProviders.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.OAuth; + +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/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs new file mode 100644 index 00000000..67a8c97e --- /dev/null +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; +using OpenShock.API.OAuth; +using OpenShock.Common.Services.Session; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Finalize an OAuth flow by creating a new account with the external identity. + /// + /// + /// Authenticates via the temporary OAuth flow cookie (set during the provider callback). + /// Sets the regular session cookie on success. No access/refresh tokens are returned. + /// + /// Provider key (e.g. discord). + /// Request body containing optional Email and Username overrides. + /// + /// + /// Account created, external identity linked, and client authenticated. + /// Flow not found, missing data, or invalid username. + /// External already linked or username/email already exists. + [EnableRateLimiting("auth")] + [HttpPost("{provider}/signup-finalize")] + [ProducesResponseType(typeof(LoginV2OkResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] + public async Task OAuthSignupFinalize( + [FromRoute] string provider, + [FromBody] OAuthFinalizeRequest body, + [FromServices] IOAuthConnectionService connectionService, + CancellationToken cancellationToken) + { + // If domain is not supported for cookies, cancel the flow + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.InternalError); + } + + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) + { + return error switch + { + OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), + _ => Problem(OAuthError.InternalError) + }; + } + + if (User.HasOpenShockUserIdentity()) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + // 1) Defense-in-depth: ensure the flow’s provider matches the route + if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase) || auth.Flow != OAuthFlow.LoginOrCreate) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowMismatch); + } + + // External identity basics from claims (added by your handler) + var externalAccountEmail = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var username = body.Username ?? auth.ExternalAccountDisplayName ?? auth.ExternalAccountName; + var email = body.Email ?? externalAccountEmail; + + if (string.IsNullOrWhiteSpace(auth.ExternalAccountId) || string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(username)) + { + return Problem(OAuthError.FlowMissingData); + } + + var isVerifiedString = auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value; + var isEmailTrusted = IsTruthy(isVerifiedString) && string.Equals(externalAccountEmail, email, StringComparison.InvariantCultureIgnoreCase); + + // Do not allow creation if this external is already linked anywhere. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken); + if (existing is not null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + var created = await _accountService.CreateOAuthOnlyAccountAsync( + email, + username, + provider, + auth.ExternalAccountId, + auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, + isEmailTrusted + ); + + if (!created.TryPickT0(out var newUser, out _)) + { + // Username or email already exists — conflict. + // Do NOT clear the flow cookie so the frontend can retry with a different username. + return Problem(SignupError.UsernameOrEmailExists); + } + + // Authenticate the client if its activated (create session and set session cookie) + if (newUser.Value.ActivatedAt is not null) + { + await CreateSession(newUser.Value.Id, domain); + } + + // Clear the temporary OAuth flow cookie. + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + return Ok(LoginV2OkResponse.FromUser(newUser.Value)); + + static bool IsTruthy(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return value.Trim().ToLowerInvariant() switch + { + "0" or "no" or "false" => false, + "1" or "yes" or "true" => true, + _ => false + }; + } + } +} diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs new file mode 100644 index 00000000..fdfe80af --- /dev/null +++ b/API/Controller/OAuth/SignupGetData.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Models.Response; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; +using OpenShock.API.OAuth; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Provides temporary OAuth handoff details for the active signup flow. + /// + /// + /// Returns basic identity information from the external provider (e.g., email, display name) + /// together with the expiration time of the current flow. + /// Access to this endpoint requires the temporary OAuth flow cookie and is restricted to the user + /// who initiated the flow. + /// + /// The provider key (e.g. discord). + /// Handoff information returned successfully. + /// No active flow found, or the provider did not match. + [ResponseCache(NoStore = true)] + [EnableRateLimiting("auth")] + [HttpGet("{provider}/signup-data")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthSignupGetData([FromRoute] string provider) + { + if (User.HasOpenShockUserIdentity()) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) + { + return error switch + { + OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), + _ => Problem(OAuthError.InternalError) + }; + } + + if (auth.Provider != provider) + { + return Problem(OAuthError.ProviderMismatch); + } + + if (auth.Flow != OAuthFlow.LoginOrCreate) + { + return Problem(OAuthError.FlowMismatch); + } + + return Ok(new OAuthSignupDataResponse + { + Provider = auth.Provider, + Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, + DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value ?? auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value, + ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime + }); + } +} diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs new file mode 100644 index 00000000..5da612d1 --- /dev/null +++ b/API/Controller/OAuth/_ApiController.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using OneOf; +using OpenShock.API.OAuth; +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; + } + + private enum OAuthValidationError + { + FlowStateMissing, + FlowDataMissingOrInvalid, + } + + /// + /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable. + /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details. + /// + private async Task> ValidateOAuthFlowAsync() + { + // 1) authenticate temp cookie + var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); + if (!auth.Succeeded || auth.Principal is null || auth.Ticket is null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return OAuthValidationError.FlowStateMissing; + } + + // 2) scheme/provider check - prefer the ticket's scheme over a magic Item + if (!auth.Properties.Items.TryGetValue(".AuthScheme", out var actualScheme) || string.IsNullOrWhiteSpace(actualScheme)) + { + _logger.LogError("Invalid OAuth scheme"); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return OAuthValidationError.FlowDataMissingOrInvalid; + } + + // 3) parse flow from properties + if (auth.Properties is null || + !auth.Properties.Items.TryGetValue(OAuthConstants.ItemKeyFlowType, out var flowStr) || + !Enum.TryParse(flowStr, true, out var flow)) + { + _logger.LogError("Invalid OAuth scheme"); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return OAuthValidationError.FlowDataMissingOrInvalid; + } + + // 4) fetch id of external user + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrWhiteSpace(externalId)) + { + _logger.LogError("Invalid OAuth scheme"); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return OAuthValidationError.FlowDataMissingOrInvalid; + } + + string? displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + string? globalName = auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value; + + return new ValidatedFlowContext( + Provider: actualScheme, + Flow: flow, + ExternalAccountId: externalId, + ExternalAccountName: globalName ?? displayName, + ExternalAccountDisplayName: displayName ?? globalName, + Principal: auth.Principal, + Properties: auth.Properties + ); + } +} \ No newline at end of file diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs new file mode 100644 index 00000000..75044ba1 --- /dev/null +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -0,0 +1,19 @@ +using OpenShock.Common.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class OAuthFinalizeRequest +{ + /// Desired username (create only). If omitted, a name will be generated from the external profile. + [Username(true)] + public required string? Username { get; init; } + + [EmailAddress(true)] + public required string? Email { get; init; } + + /// + /// New account password (create only). + /// + [Password(true)] + public required string? Password { get; init; } +} \ No newline at end of file diff --git a/API/Models/Response/LoginV2OkResponse.cs b/API/Models/Response/LoginV2OkResponse.cs index 6a487afb..763493c6 100644 --- a/API/Models/Response/LoginV2OkResponse.cs +++ b/API/Models/Response/LoginV2OkResponse.cs @@ -9,6 +9,7 @@ public sealed class LoginV2OkResponse public required Guid AccountId { get; init; } public required string AccountName { get; init; } public required string AccountEmail { get; init; } + public required bool IsVerified { get; init; } public required Uri ProfileImage { get; init; } public required List AccountRoles { get; init; } @@ -17,6 +18,7 @@ public sealed class LoginV2OkResponse AccountId = argUser.Id, AccountName = argUser.Name, AccountEmail = argUser.Email, + IsVerified = argUser.ActivatedAt is not null, ProfileImage = argUser.GetImageUrl(), AccountRoles = argUser.Roles }; 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/OAuthSignupDataResponse.cs b/API/Models/Response/OAuthSignupDataResponse.cs new file mode 100644 index 00000000..98617473 --- /dev/null +++ b/API/Models/Response/OAuthSignupDataResponse.cs @@ -0,0 +1,10 @@ +namespace OpenShock.API.Models.Response; + +// what we return to frontend at /oauth/discord/data +public sealed class OAuthSignupDataResponse +{ + 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/OAuth/AuthenticationSchemeProviderExtensions.cs b/API/OAuth/AuthenticationSchemeProviderExtensions.cs new file mode 100644 index 00000000..5da2d4d9 --- /dev/null +++ b/API/OAuth/AuthenticationSchemeProviderExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authentication; + +namespace OpenShock.API.OAuth; + +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 => OAuthConstants.OAuth2Schemes.Contains(scheme)) + .ToArray(); + } + public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme) + { + if (!OAuthConstants.OAuth2Schemes.Contains(scheme)) + return false; + + var schemes = await provider.GetAllSchemesAsync(); + + return schemes.Any(s => s.Name == scheme); + } +} diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs new file mode 100644 index 00000000..0ca755c3 --- /dev/null +++ b/API/OAuth/OAuthConstants.cs @@ -0,0 +1,15 @@ +namespace OpenShock.API.OAuth; + +public static class OAuthConstants +{ + public const string FlowScheme = "OAuthFlowCookie"; + public const string FlowCookieName = ".OpenShock.OAuthFlow"; + + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; + + public const string ItemKeyFlowType = ".FlowType"; + + public const string ClaimEmailVerified = "openshock.oauth.email_verified"; + public const string ClaimGlobalName = "openshock.oauth.global_name"; +} \ No newline at end of file diff --git a/API/OAuth/OAuthError.cs b/API/OAuth/OAuthError.cs new file mode 100644 index 00000000..6f042505 --- /dev/null +++ b/API/OAuth/OAuthError.cs @@ -0,0 +1,66 @@ +using System.Net; +using OpenShock.Common.Problems; + +namespace OpenShock.API.OAuth; + +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 FlowMismatch => new( + "OAuth.Flow.Mismatch", + "This OAuth flow differs from the flow the oauth flow started with", + HttpStatusCode.Forbidden); + + public static OpenShockProblem AnonymousOnlyEndpoint => new( + "OAuth.Flow.AnonymousOnlyEndpoint", + "You must be signed out to call this endpoint", + HttpStatusCode.Unauthorized); + + 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); +} \ No newline at end of file diff --git a/API/OAuth/OAuthFlow.cs b/API/OAuth/OAuthFlow.cs new file mode 100644 index 00000000..2caf75c4 --- /dev/null +++ b/API/OAuth/OAuthFlow.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.OAuth; + +public enum OAuthFlow +{ + LoginOrCreate, + Link +} \ No newline at end of file diff --git a/API/OAuth/OAuthUtil.cs b/API/OAuth/OAuthUtil.cs new file mode 100644 index 00000000..5ae53549 --- /dev/null +++ b/API/OAuth/OAuthUtil.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; + +namespace OpenShock.API.OAuth; + +public static class OAuthUtil +{ + public static ChallengeResult StartOAuth(string provider, OAuthFlow flow) + { + return new ChallengeResult(provider, new AuthenticationProperties + { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { OAuthConstants.ItemKeyFlowType, flow.ToString() } } + }); + } +} \ No newline at end of file diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs new file mode 100644 index 00000000..3bca965f --- /dev/null +++ b/API/OAuth/ValidatedFlowContext.cs @@ -0,0 +1,6 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace OpenShock.API.OAuth; + +public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, string? ExternalAccountName, string? ExternalAccountDisplayName, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ 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..b70c969e --- /dev/null +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -0,0 +1,11 @@ + + +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; } +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 129fde32..7542829b 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,8 +1,12 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; +using OpenShock.API.OAuth; +using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services.Account; using OpenShock.API.Services.DeviceUpdate; using OpenShock.API.Services.Email; +using OpenShock.API.Services.OAuthConnection; using OpenShock.API.Services.Turnstile; using OpenShock.API.Services.UserService; using OpenShock.Common; @@ -26,7 +30,38 @@ builder.Services .AddOpenShockMemDB(redisOptions) .AddOpenShockDB(databaseOptions) - .AddOpenShockServices() + .AddOpenShockServices(auth => + { + auth.AddCookie(OAuthConstants.FlowScheme, o => { + o.Cookie.Name = OAuthConstants.FlowCookieName; + o.ExpireTimeSpan = TimeSpan.FromMinutes(10); + o.SlidingExpiration = false; + }); + + var options = builder.Configuration.GetSection(DiscordOAuthOptions.SectionName).Get(); + if (options is not null) + { + auth.AddDiscord(OAuthConstants.DiscordScheme, o => { + o.SignInScheme = OAuthConstants.FlowScheme; + + + + o.ClientId = options.ClientId; + o.ClientSecret = options.ClientSecret; + o.CallbackPath = "/oauth/discord/callback"; + o.CallbackPath = "/oauth/discord/rejected"; // TODO: Make this do something + o.Scope.Add("email"); + + o.Prompt = "none"; + o.SaveTokens = false; + + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimGlobalName, "global_name"); + + o.Validate(); + }); + } + }) .AddOpenShockSignalR(redisOptions); builder.Services.AddScoped(); @@ -34,6 +69,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 054d6d7e..0f527a48 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,94 @@ 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, + bool isEmailTrusted) + { + 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(); + + string? activationToken = null; + + try + { + var creationTime = DateTime.UtcNow; + + var user = new User + { + Id = Guid.CreateVersion7(), + Name = username, + Email = email, + PasswordHash = null, + CreatedAt = creationTime, + ActivatedAt = isEmailTrusted ? creationTime : null + }; + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + // If email isn't trusted, create an activation request (email verification) + if (!isEmailTrusted) + { + activationToken = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); + + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = HashingUtils.HashToken(activationToken), + CreatedAt = creationTime + }; + + await _db.SaveChangesAsync(); + } + + // Link external identity + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = user.Id, + ProviderKey = provider, + ExternalId = providerAccountId, + DisplayName = providerAccountName, + CreatedAt = creationTime, + }); + + await _db.SaveChangesAsync(); + + await tx.CommitAsync(); + + // Send verification email only after successful commit + if (!isEmailTrusted && activationToken is not null) + { + await _emailService.VerifyEmail( + new Contact(email, username), + new Uri(_frontendConfig.BaseUrl, $"/#/account/activate/{user.Id}/{activationToken}") + ); + } + + return new Success(user); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + await tx.RollbackAsync(); + return new AccountWithEmailOrUsernameExists(); + } + } + + public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(secret); @@ -249,12 +338,12 @@ public async Task - public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, - LoginContext loginContext, CancellationToken cancellationToken = default) + /// + public async Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); var user = await _db.Users + .AsNoTracking() .Include(u => u.UserDeactivation) .FirstOrDefaultAsync(x => x.Email == lowercaseUsernameOrEmail || x.Name == lowercaseUsernameOrEmail, cancellationToken); if (user is null) @@ -262,25 +351,29 @@ public async Task @@ -447,6 +540,11 @@ 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..c7d22cd5 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -28,6 +28,20 @@ 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, bool isEmailTrusted); + /// /// /// @@ -43,15 +57,14 @@ public interface IAccountService public Task> DeleteAccountAsync(Guid executingUserId, Guid userId); /// - /// Login a user into his user session + /// Get a user by credentials /// /// /// - /// /// /// - public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); - + public Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, 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..ab19d5eb --- /dev/null +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -0,0 +1,16 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.OAuthConnection; + +/// +/// Manages external OAuth connections for users. +/// +public interface IOAuthConnectionService +{ + Task GetConnectionsAsync(Guid userId, CancellationToken cancellationToken = default); + Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); + Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); + Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken = default); + Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); +} \ 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..10170b02 --- /dev/null +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.OAuthConnection; + +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, CancellationToken cancellationToken) + { + return await _db.UserOAuthConnections + .AsNoTracking() + .Where(c => c.UserId == userId) + .ToArrayAsync(cancellationToken); + } + + public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections + .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken); + } + + public async Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections.AnyAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken); + } + + public async Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p, cancellationToken); + } + + public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken) + { + try + { + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = userId, + ProviderKey = provider.ToLowerInvariant(), + ExternalId = providerAccountId, + DisplayName = providerAccountName + }); + await _db.SaveChangesAsync(cancellationToken); + 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, CancellationToken cancellationToken) + { + var p = provider.ToLowerInvariant(); + var nDeleted = await _db.UserOAuthConnections + .Where(c => c.UserId == userId && c.ProviderKey == p) + .ExecuteDeleteAsync(cancellationToken); + + return nDeleted > 0; + } +} diff --git a/Common/Common.csproj b/Common/Common.csproj index 063e25d2..4f8349fe 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + 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/LoginError.cs b/Common/Errors/LoginError.cs index 8185ce98..d6f8d10b 100644 --- a/Common/Errors/LoginError.cs +++ b/Common/Errors/LoginError.cs @@ -5,6 +5,6 @@ namespace OpenShock.Common.Errors; public static class LoginError { - public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid credentials provided", HttpStatusCode.Unauthorized); + public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid username or password", HttpStatusCode.Unauthorized); public static OpenShockProblem InvalidDomain => new OpenShockProblem("Login.InvalidDomain", "The url you are requesting a login from is not whitelisted", HttpStatusCode.Forbidden); } \ No newline at end of file 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/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 658cee34..afb164ff 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -1,7 +1,12 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using OpenShock.Common.Constants; using OpenShock.Common.Models; +using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Session; +using OpenShock.Common.Utils; namespace OpenShock.Common; @@ -9,23 +14,62 @@ namespace OpenShock.Common; public class OpenShockControllerBase : ControllerBase { [NonAction] - public ObjectResult Problem(OpenShockProblem problem) => problem.ToObjectResult(HttpContext); + protected ObjectResult Problem(OpenShockProblem problem) => problem.ToObjectResult(HttpContext); [NonAction] - public OkObjectResult LegacyDataOk(T data) + protected OkObjectResult LegacyDataOk(T data) { return Ok(new LegacyDataResponse(data)); } [NonAction] - public CreatedResult LegacyDataCreated(string? uri, T data) + protected CreatedResult LegacyDataCreated(string? uri, T data) { return Created(uri, new LegacyDataResponse(data)); } [NonAction] - public OkObjectResult LegacyEmptyOk(string message = "") + protected OkObjectResult LegacyEmptyOk(string message = "") { return Ok(new LegacyEmptyResponse(message)); } + + [NonAction] + protected string? GetCurrentCookieDomain() + { + var cookieDomains = HttpContext.RequestServices.GetRequiredService().CookieDomains; + return DomainUtils.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, cookieDomains); + } + + private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) + { + return new CookieOptions + { + Expires = DateTimeOffset.UtcNow.Add(lifetime), + Secure = true, + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Domain = domain + }; + } + + [NonAction] + protected async Task CreateSession(Guid accountId, string domain) + { + var sessionService = HttpContext.RequestServices.GetRequiredService(); + + var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); + + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, GetCookieOptions(domain, Duration.LoginSessionLifetime)); + } + + [NonAction] + protected void RemoveSessionKeyCookie() + { + var cookieDomains = HttpContext.RequestServices.GetRequiredService().CookieDomains; + foreach (var domain in cookieDomains) + { + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); + } + } } \ No newline at end of file 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 9610a851..00aeffb1 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; @@ -65,8 +66,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(); @@ -86,15 +88,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")); diff --git a/Common/Utils/AuthUtils.cs b/Common/Utils/AuthUtils.cs index 4c323389..dce768bf 100644 --- a/Common/Utils/AuthUtils.cs +++ b/Common/Utils/AuthUtils.cs @@ -1,6 +1,7 @@ using OpenShock.Common.Constants; using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using OpenShock.Common.Authentication; namespace OpenShock.Common.Utils; @@ -16,28 +17,6 @@ public static class AuthUtils "Device-Token" ]; - private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) - { - return new CookieOptions - { - Expires = new DateTimeOffset(DateTime.UtcNow.Add(lifetime)), - Secure = true, - HttpOnly = true, - SameSite = SameSiteMode.Strict, - Domain = domain - }; - } - - public static void SetSessionKeyCookie(this HttpContext context, string sessionKey, string domain) - { - context.Response.Cookies.Append(AuthConstants.UserSessionCookieName, sessionKey, GetCookieOptions(domain, Duration.LoginSessionLifetime)); - } - - public static void RemoveSessionKeyCookie(this HttpContext context, string domain) - { - context.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); - } - public static bool TryGetUserSessionToken(this HttpContext context, [NotNullWhen(true)] out string? sessionToken) { if (context.Request.Cookies.TryGetValue(AuthConstants.UserSessionCookieName, out sessionToken) && !string.IsNullOrEmpty(sessionToken)) @@ -101,4 +80,67 @@ public static string GetAuthenticationMethod(this HttpContext context) return authMethodClaim.Value; } + public static bool HasOpenShockUserIdentity(this ClaimsPrincipal user) + { + foreach (var ident in user.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + return true; + } + } + } + + return false; + } + + public static bool TryGetOpenShockUserIdentity(this ClaimsPrincipal user, [NotNullWhen(true)] out ClaimsIdentity? identity) + { + foreach (var ident in user.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + identity = ident; + return true; + } + } + } + + identity = null; + return false; + } + + public static bool TryGetAuthenticatedOpenShockUserId(this ClaimsPrincipal user, out Guid userId) + { + if (!user.TryGetOpenShockUserIdentity(out var identity)) + { + userId = Guid.Empty; + return false; + } + + var idStr = identity.Claims.SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(idStr)) + { + userId = Guid.Empty; + return false; + } + + return Guid.TryParse(idStr, out userId); + } }