Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
8e2568d
feat: add discord oauth login
LucHeart Sep 2, 2025
ee05a11
Copy over some OAuth logic from ZapMe
hhvrc Sep 2, 2025
259b0e1
Update OAuthAuthenticate.cs
hhvrc Sep 2, 2025
7fcdfae
More cleanup
hhvrc Sep 2, 2025
ee06381
Move some stuff around
hhvrc Sep 2, 2025
53cfa0e
Merge branch 'develop' into codex/add-oauth-signup-and-login-via-discord
hhvrc Sep 2, 2025
4da7889
Update IAuthenticationSchemeProviderExtensions.cs
hhvrc Sep 2, 2025
2770175
Some more cleanup
hhvrc Sep 2, 2025
d4860dd
Remove Microsoft.Authentication base OAuth handler
hhvrc Sep 2, 2025
70eff56
Update API.csproj
hhvrc Sep 2, 2025
6f2f4d0
Clean up imports
hhvrc Sep 2, 2025
352ac96
More cleanup
hhvrc Sep 2, 2025
57378bd
More cleanup
hhvrc Sep 2, 2025
acc56e0
VERY basic implementation
hhvrc Sep 2, 2025
9761179
Broader implementation
hhvrc Sep 2, 2025
59d0be0
More reverts
hhvrc Sep 2, 2025
27bf171
Fix up some more stuff
hhvrc Sep 2, 2025
54744e2
Improve implementation
hhvrc Sep 2, 2025
bd46c45
Attempt to fix integration test failure
hhvrc Sep 2, 2025
20d0baa
Fail password logins for OAuth accounts
hhvrc Sep 2, 2025
3d96883
Add endpoint to list OAuth connections for current account
hhvrc Sep 2, 2025
debcb18
Add connection delete endpoint
hhvrc Sep 2, 2025
7d8e2f3
Create AddConnection endpoint
hhvrc Sep 2, 2025
19c2e9d
Oops
hhvrc Sep 2, 2025
094d43a
Add TryAdd
hhvrc Sep 2, 2025
eb53769
Fix FK issue and rename DB Model
hhvrc Sep 2, 2025
0df7580
Move controllers around a bit
hhvrc Sep 3, 2025
98ea99e
More improvements
hhvrc Sep 3, 2025
065b6a4
Clean up more stuff
hhvrc Sep 3, 2025
a89bff0
Reduce filecount
hhvrc Sep 3, 2025
0059a65
Let's not reinvent the wheel...
hhvrc Sep 3, 2025
254bbf1
Clean up Complete endpoint logic a bit
hhvrc Sep 3, 2025
53884b3
Better?
hhvrc Sep 3, 2025
8bd6816
Yeah.......
hhvrc Sep 3, 2025
9cf7243
inbetween swapping back again...
hhvrc Sep 3, 2025
0d8928a
Idk where this is going
hhvrc Sep 3, 2025
a37b988
Absolute cinema.
hhvrc Sep 4, 2025
3dad263
What now?
hhvrc Sep 4, 2025
21db899
Use proper frontend endpoints
hhvrc Sep 4, 2025
2ca0b38
More cleanup
hhvrc Sep 4, 2025
0e2ba32
Code quality improvements
hhvrc Sep 4, 2025
0fd6e3d
More docs and clean up stuff
hhvrc Sep 4, 2025
7e9a5fa
Revert other changes done by Codex
hhvrc Sep 4, 2025
4b768ab
Move stuff to API csproj
hhvrc Sep 4, 2025
41b12a0
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 4, 2025
3c813ea
Revert changes in ApiTokenAuthentication.cs
hhvrc Sep 4, 2025
c1c9fda
More cleanup
hhvrc Sep 4, 2025
80c70bc
More work on oauth session initiarion
hhvrc Sep 8, 2025
9f51b1b
Unify cookie logic
hhvrc Sep 9, 2025
93398ce
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 10, 2025
bc34d97
More work on stuff
hhvrc Sep 10, 2025
b0f1c03
Some improvements to domain matching
hhvrc Sep 10, 2025
1a6bf4f
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 10, 2025
7e4ee77
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 11, 2025
9d52bda
Fix some errors
hhvrc Sep 11, 2025
9107e8c
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 11, 2025
4dc0e71
More stuff
hhvrc Sep 12, 2025
d2f16ed
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 12, 2025
e78fe00
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 12, 2025
e06b230
Fix other places
hhvrc Sep 12, 2025
60e8380
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 12, 2025
09e924e
Fix cookie remover
hhvrc Sep 12, 2025
e2215c3
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 12, 2025
e2e55ae
Some other fixes
hhvrc Sep 12, 2025
55247b0
Update DomainUtils.cs
hhvrc Sep 12, 2025
240d9e9
Make discord OAuth2 options optional
hhvrc Sep 12, 2025
0742814
Final touchups?
hhvrc Sep 12, 2025
56e9dfd
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 12, 2025
fda853e
Update AccountService.cs
hhvrc Sep 12, 2025
ffd3593
Clean up more
hhvrc Sep 12, 2025
ba7f28f
More touchups
hhvrc Sep 14, 2025
e61bf8a
Merge branch 'develop' into feature/add-oauth-support
hhvrc Sep 14, 2025
a8da019
Don't sign in if email unverified
hhvrc Sep 14, 2025
337f395
Fix name and displayname resolution
hhvrc Sep 14, 2025
ce18b73
Make requested changes
hhvrc Sep 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Import Project="../Shared.props" />

<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Discord" Version="9.4.0" />
<PackageReference Include="Fluid.Core" Version="2.25.0" />
<PackageReference Include="MailKit" Version="4.13.0" />
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> 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);
}
Expand Down
34 changes: 34 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionAdd.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Start linking an OAuth provider to the current account.
/// </summary>
/// <remarks>
/// Initiates the OAuth flow (link mode) for a given provider.
/// On success this returns a <c>302 Found</c> to the provider's authorization page.
/// After consent, the OAuth middleware will call the internal callback and finally
/// redirect to <c>/1/oauth/{provider}/handoff</c>.
/// </remarks>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <param name="schemeProvider"></param>
/// <response code="302">Redirect to the provider authorization page.</response>
/// <response code="400">Unsupported or misconfigured provider.</response>
[HttpGet("connections/{provider}/link")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
public async Task<IActionResult> AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider)
{
if (!await schemeProvider.IsSupportedOAuthScheme(provider))
return Problem(OAuthError.UnsupportedProvider);

return OAuthUtil.StartOAuth(provider, OAuthFlow.Link);
}
}
28 changes: 28 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
ο»Ώusing Microsoft.AspNetCore.Mvc;
using OpenShock.API.Services.OAuthConnection;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// Remove an existing OAuth connection for the current user.
/// </summary>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <param name="connectionService"></param>
/// <param name="cancellationToken"></param>
/// <response code="204">Connection removed.</response>
/// <response code="404">No connection found for this provider.</response>
[HttpDelete("connections/{provider}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
{
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken);

if (!deleted)
return NotFound();

return NoContent();
}
}
30 changes: 30 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionsList.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// List OAuth connections linked to the current user.
/// </summary>
/// <returns>Array of connections with provider key, external id, display name and link time.</returns>
/// <response code="200">Returns the list of connections.</response>
[HttpGet("connections")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<OAuthConnectionResponse[]> 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();
}
}
31 changes: 14 additions & 17 deletions API/Controller/Account/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,27 +29,23 @@ public sealed partial class AccountController
[MapToApiVersion("1")]
public async Task<IActionResult> 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<IActionResult>(
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");
}
}
46 changes: 22 additions & 24 deletions API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -33,39 +35,35 @@ public sealed partial class AccountController
public async Task<IActionResult> 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<IActionResult>(
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));
}
}
17 changes: 2 additions & 15 deletions API/Controller/Account/Logout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ public sealed partial class AccountController
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
[MapToApiVersion("1")]
public async Task<IActionResult> Logout(
[FromServices] ISessionService sessionService,
[FromServices] FrontendOptions options)
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService)
{
// Remove session if valid
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
Expand All @@ -23,18 +21,7 @@ public async Task<IActionResult> 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();
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Signup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public async Task<IActionResult> SignUp([FromBody] SignUp body)
var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password);
return creationAction.Match<IActionResult>(
ok => LegacyEmptyOk("Successfully signed up"),
alreadyExists => Problem(SignupError.EmailAlreadyExists)
alreadyExists => Problem(SignupError.UsernameOrEmailExists)
);
}
}
2 changes: 1 addition & 1 deletion API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public async Task<IActionResult> SignUpV2(
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
return creationAction.Match<IActionResult>(
_ => Ok(),
_ => Problem(SignupError.EmailAlreadyExists)
_ => Problem(SignupError.UsernameOrEmailExists)
);
}
}
38 changes: 38 additions & 0 deletions API/Controller/OAuth/Authorize.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Start OAuth authorization for a given provider (login-or-create flow).
/// </summary>
/// <remarks>
/// Initiates an OAuth challenge in "login-or-create" mode.
/// Returns <c>302</c> redirect to the provider authorization page.
/// </remarks>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <response code="302">Redirect to the provider authorization page.</response>
/// <response code="400">Unsupported or misconfigured provider.</response>
[EnableRateLimiting("auth")]
[HttpGet("{provider}/authorize")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
public async Task<IActionResult> 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);
}
}
Loading