Skip to content

Commit 6bc3093

Browse files
hhvrcLucHeart
andauthored
feat: Add oauth support (#235)
* feat: add discord oauth login * Copy over some OAuth logic from ZapMe * Update OAuthAuthenticate.cs * More cleanup * Move some stuff around * Update IAuthenticationSchemeProviderExtensions.cs * Some more cleanup * Remove Microsoft.Authentication base OAuth handler * Update API.csproj * Clean up imports * More cleanup * More cleanup * VERY basic implementation * Broader implementation * More reverts * Fix up some more stuff * Improve implementation * Attempt to fix integration test failure * Fail password logins for OAuth accounts * Add endpoint to list OAuth connections for current account * Add connection delete endpoint * Create AddConnection endpoint * Oops * Add TryAdd * Fix FK issue and rename DB Model * Move controllers around a bit * More improvements * Clean up more stuff * Reduce filecount * Let's not reinvent the wheel... * Clean up Complete endpoint logic a bit * Better? * Yeah....... * inbetween swapping back again... * Idk where this is going * Absolute cinema. * What now? * Use proper frontend endpoints * More cleanup * Code quality improvements * More docs and clean up stuff * Revert other changes done by Codex * Move stuff to API csproj * Revert changes in ApiTokenAuthentication.cs * More cleanup * More work on oauth session initiarion * Unify cookie logic * More work on stuff * Some improvements to domain matching * Fix some errors * More stuff * Fix other places * Fix cookie remover * Some other fixes * Update DomainUtils.cs * Make discord OAuth2 options optional * Final touchups? * Update AccountService.cs * Clean up more * More touchups * Don't sign in if email unverified * Fix name and displayname resolution * Make requested changes --------- Co-authored-by: Luc ♥ <luc@luc.cat>
1 parent 10f55e5 commit 6bc3093

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2869
-112
lines changed

API/API.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<Import Project="../Shared.props" />
44

55
<ItemGroup>
6+
<PackageReference Include="AspNet.Security.OAuth.Discord" Version="9.4.0" />
67
<PackageReference Include="Fluid.Core" Version="2.25.0" />
78
<PackageReference Include="MailKit" Version="4.13.0" />
89
</ItemGroup>

API/Controller/Account/Authenticated/ChangePassword.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController
1717
[ProducesResponseType(StatusCodes.Status200OK)]
1818
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest data)
1919
{
20-
if (!HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified)
20+
if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified)
2121
{
2222
return Problem(AccountError.PasswordChangeInvalidPassword);
2323
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Mvc;
3+
using OpenShock.Common.Problems;
4+
using System.Net.Mime;
5+
using OpenShock.API.OAuth;
6+
7+
namespace OpenShock.API.Controller.Account.Authenticated;
8+
9+
public sealed partial class AuthenticatedAccountController
10+
{
11+
/// <summary>
12+
/// Start linking an OAuth provider to the current account.
13+
/// </summary>
14+
/// <remarks>
15+
/// Initiates the OAuth flow (link mode) for a given provider.
16+
/// On success this returns a <c>302 Found</c> to the provider's authorization page.
17+
/// After consent, the OAuth middleware will call the internal callback and finally
18+
/// redirect to <c>/1/oauth/{provider}/handoff</c>.
19+
/// </remarks>
20+
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
21+
/// <param name="schemeProvider"></param>
22+
/// <response code="302">Redirect to the provider authorization page.</response>
23+
/// <response code="400">Unsupported or misconfigured provider.</response>
24+
[HttpGet("connections/{provider}/link")]
25+
[ProducesResponseType(StatusCodes.Status302Found)]
26+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
27+
public async Task<IActionResult> AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider)
28+
{
29+
if (!await schemeProvider.IsSupportedOAuthScheme(provider))
30+
return Problem(OAuthError.UnsupportedProvider);
31+
32+
return OAuthUtil.StartOAuth(provider, OAuthFlow.Link);
33+
}
34+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using OpenShock.API.Services.OAuthConnection;
3+
4+
namespace OpenShock.API.Controller.Account.Authenticated;
5+
6+
public sealed partial class AuthenticatedAccountController
7+
{
8+
/// <summary>
9+
/// Remove an existing OAuth connection for the current user.
10+
/// </summary>
11+
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
12+
/// <param name="connectionService"></param>
13+
/// <param name="cancellationToken"></param>
14+
/// <response code="204">Connection removed.</response>
15+
/// <response code="404">No connection found for this provider.</response>
16+
[HttpDelete("connections/{provider}")]
17+
[ProducesResponseType(StatusCodes.Status204NoContent)]
18+
[ProducesResponseType(StatusCodes.Status404NotFound)]
19+
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
20+
{
21+
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken);
22+
23+
if (!deleted)
24+
return NotFound();
25+
26+
return NoContent();
27+
}
28+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using OpenShock.API.Models.Response;
3+
using OpenShock.API.Services.OAuthConnection;
4+
5+
namespace OpenShock.API.Controller.Account.Authenticated;
6+
7+
public sealed partial class AuthenticatedAccountController
8+
{
9+
/// <summary>
10+
/// List OAuth connections linked to the current user.
11+
/// </summary>
12+
/// <returns>Array of connections with provider key, external id, display name and link time.</returns>
13+
/// <response code="200">Returns the list of connections.</response>
14+
[HttpGet("connections")]
15+
[ProducesResponseType(StatusCodes.Status200OK)]
16+
public async Task<OAuthConnectionResponse[]> ListOAuthConnections([FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
17+
{
18+
var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id, cancellationToken);
19+
20+
return connections
21+
.Select(c => new OAuthConnectionResponse
22+
{
23+
ProviderKey = c.ProviderKey,
24+
ExternalId = c.ExternalId,
25+
DisplayName = c.DisplayName,
26+
LinkedAt = c.CreatedAt
27+
})
28+
.ToArray();
29+
}
30+
}

API/Controller/Account/Login.cs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using OpenShock.Common.Utils;
1111
using System.Net.Mime;
1212
using Microsoft.AspNetCore.RateLimiting;
13+
using OpenShock.Common.Services.Session;
1314

1415
namespace OpenShock.API.Controller.Account;
1516

@@ -28,27 +29,23 @@ public sealed partial class AccountController
2829
[MapToApiVersion("1")]
2930
public async Task<IActionResult> Login(
3031
[FromBody] Login body,
31-
[FromServices] FrontendOptions options,
3232
CancellationToken cancellationToken)
3333
{
34-
var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
35-
if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
34+
var cookieDomain = GetCurrentCookieDomain();
35+
if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
3636

37-
var loginAction = await _accountService.CreateUserLoginSessionAsync(body.Email, body.Password, new LoginContext
37+
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken);
38+
if (!getAccountResult.TryPickT0(out var account, out var errors))
3839
{
39-
Ip = HttpContext.GetRemoteIP().ToString(),
40-
UserAgent = HttpContext.GetUserAgent(),
41-
}, cancellationToken);
40+
return errors.Match(
41+
notFound => Problem(LoginError.InvalidCredentials),
42+
deactivated => Problem(AccountError.AccountDeactivated),
43+
notActivated => Problem(AccountError.AccountNotActivated),
44+
oauthOnly => Problem(AccountError.AccountOAuthOnly)
45+
);
46+
}
4247

43-
return loginAction.Match<IActionResult>(
44-
ok =>
45-
{
46-
HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse);
47-
return LegacyEmptyOk("Successfully logged in");
48-
},
49-
notActivated => Problem(AccountError.AccountNotActivated),
50-
deactivated => Problem(AccountError.AccountDeactivated),
51-
notFound => Problem(LoginError.InvalidCredentials)
52-
);
48+
await CreateSession(account.Id, cookieDomain);
49+
return LegacyEmptyOk("Successfully logged in");
5350
}
5451
}

API/Controller/Account/LoginV2.cs

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Net;
44
using System.Net.Mime;
55
using Asp.Versioning;
6+
using Microsoft.AspNetCore.Authentication;
67
using Microsoft.AspNetCore.RateLimiting;
78
using OpenShock.API.Services.Account;
89
using OpenShock.Common.Errors;
@@ -14,6 +15,7 @@
1415
using OpenShock.API.Models.Response;
1516
using OpenShock.API.Services.Turnstile;
1617
using OpenShock.Common.Options;
18+
using OpenShock.Common.Services.Session;
1719

1820
namespace OpenShock.API.Controller.Account;
1921

@@ -33,39 +35,35 @@ public sealed partial class AccountController
3335
public async Task<IActionResult> LoginV2(
3436
[FromBody] LoginV2 body,
3537
[FromServices] ICloudflareTurnstileService turnstileService,
36-
[FromServices] FrontendOptions options,
3738
CancellationToken cancellationToken)
3839
{
39-
var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
40-
if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
40+
var cookieDomain = GetCurrentCookieDomain();
41+
if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
4142

42-
var remoteIP = HttpContext.GetRemoteIP();
43+
var remoteIp = HttpContext.GetRemoteIP();
4344

44-
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIP, cancellationToken);
45-
if (!turnStile.IsT0)
45+
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken);
46+
if (!turnStile.TryPickT0(out _, out var cfErrors))
4647
{
47-
var cfErrors = turnStile.AsT1.Value;
48-
if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse))
48+
if (cfErrors.Value.All(err => err == CloudflareTurnstileError.InvalidResponse))
4949
return Problem(TurnstileError.InvalidTurnstile);
5050

5151
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
5252
}
53-
54-
var loginAction = await _accountService.CreateUserLoginSessionAsync(body.UsernameOrEmail, body.Password, new LoginContext
53+
54+
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
55+
if (!getAccountResult.TryPickT0(out var account, out var errors))
5556
{
56-
Ip = remoteIP.ToString(),
57-
UserAgent = HttpContext.GetUserAgent(),
58-
}, cancellationToken);
59-
60-
return loginAction.Match<IActionResult>(
61-
ok =>
62-
{
63-
HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse);
64-
return Ok(LoginV2OkResponse.FromUser(ok.User));
65-
},
66-
notActivated => Problem(AccountError.AccountNotActivated),
67-
deactivated => Problem(AccountError.AccountDeactivated),
68-
notFound => Problem(LoginError.InvalidCredentials)
69-
);
57+
return errors.Match(
58+
notFound => Problem(LoginError.InvalidCredentials),
59+
deactivated => Problem(AccountError.AccountDeactivated),
60+
notActivated => Problem(AccountError.AccountNotActivated),
61+
oauthOnly => Problem(AccountError.AccountOAuthOnly)
62+
);
63+
}
64+
65+
await CreateSession(account.Id, cookieDomain);
66+
67+
return Ok(LoginV2OkResponse.FromUser(account));
7068
}
7169
}

API/Controller/Account/Logout.cs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ public sealed partial class AccountController
1212
[HttpPost("logout")]
1313
[ProducesResponseType(StatusCodes.Status200OK)]
1414
[MapToApiVersion("1")]
15-
public async Task<IActionResult> Logout(
16-
[FromServices] ISessionService sessionService,
17-
[FromServices] FrontendOptions options)
15+
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService)
1816
{
1917
// Remove session if valid
2018
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
@@ -23,18 +21,7 @@ public async Task<IActionResult> Logout(
2321
}
2422

2523
// Make sure cookie is removed, no matter if authenticated or not
26-
var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
27-
if (cookieDomainToUse is not null)
28-
{
29-
HttpContext.RemoveSessionKeyCookie(cookieDomainToUse);
30-
}
31-
else // Fallback to all domains
32-
{
33-
foreach (var domain in options.CookieDomains)
34-
{
35-
HttpContext.RemoveSessionKeyCookie(domain);
36-
}
37-
}
24+
RemoveSessionKeyCookie();
3825

3926
// its always a success, logout endpoints should be idempotent
4027
return Ok();

API/Controller/Account/Signup.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public async Task<IActionResult> SignUp([FromBody] SignUp body)
2727
var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password);
2828
return creationAction.Match<IActionResult>(
2929
ok => LegacyEmptyOk("Successfully signed up"),
30-
alreadyExists => Problem(SignupError.EmailAlreadyExists)
30+
alreadyExists => Problem(SignupError.UsernameOrEmailExists)
3131
);
3232
}
3333
}

API/Controller/Account/SignupV2.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public async Task<IActionResult> SignUpV2(
4646
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
4747
return creationAction.Match<IActionResult>(
4848
_ => Ok(),
49-
_ => Problem(SignupError.EmailAlreadyExists)
49+
_ => Problem(SignupError.UsernameOrEmailExists)
5050
);
5151
}
5252
}

0 commit comments

Comments
 (0)