From 6186eda092aa6d00241477c6d5390c0be473221c Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 12 Jun 2024 16:19:49 +0200 Subject: [PATCH] feat(backend): add RequestDiscordTokenAsync method --- Foxnouns.Backend/Config.cs | 20 +++++++ .../Authentication/AuthController.cs | 12 +++- .../Authentication/DiscordAuthController.cs | 53 ++++++++++++++++-- .../Authentication/EmailAuthController.cs | 19 ++++++- .../Controllers/DebugController.cs | 18 ++++-- .../Controllers/MetaController.cs | 3 + .../Extensions/WebApplicationExtensions.cs | 3 +- Foxnouns.Backend/Services/AuthService.cs | 55 +++++++++++++++++-- Foxnouns.Backend/Services/KeyCacheService.cs | 6 ++ .../Services/RemoteAuthService.cs | 48 ++++++++++++++++ .../routes/auth/login/discord/+page.server.ts | 10 ++++ .../routes/auth/login/discord/+page.svelte | 5 ++ 12 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 Foxnouns.Backend/Services/RemoteAuthService.cs create mode 100644 Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts create mode 100644 Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index bb2add8..e4d81c7 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -15,6 +15,8 @@ public class Config public DatabaseConfig Database { get; init; } = new(); public DiscordAuthConfig DiscordAuth { get; init; } = new(); + public GoogleAuthConfig GoogleAuth { get; init; } = new(); + public TumblrAuthConfig TumblrAuth { get; init; } = new(); public class DatabaseConfig { @@ -25,6 +27,24 @@ public class Config public class DiscordAuthConfig { + public bool Enabled => ClientId != null && ClientSecret != null; + + public string? ClientId { get; init; } + public string? ClientSecret { get; init; } + } + + public class GoogleAuthConfig + { + public bool Enabled => ClientId != null && ClientSecret != null; + + public string? ClientId { get; init; } + public string? ClientSecret { get; init; } + } + + public class TumblrAuthConfig + { + public bool Enabled => ClientId != null && ClientSecret != null; + public string? ClientId { get; init; } public string? ClientSecret { get; init; } } diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 96b10c3..e224be3 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -6,12 +6,16 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth")] -public class AuthController(Config config, KeyCacheService keyCacheSvc) : ApiControllerBase -{ +public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger logger) : ApiControllerBase +{ [HttpPost("urls")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UrlsResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task UrlsAsync() { + logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", + config.DiscordAuth.Enabled, + config.GoogleAuth.Enabled, + config.TumblrAuth.Enabled); var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync()); string? discord = null; if (config.DiscordAuth.ClientId != null && config.DiscordAuth.ClientSecret != null) @@ -35,4 +39,6 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc) : ApiCon string Token, Instant ExpiresAt ); + + public record CallbackRequest(string Code, string State); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 2fb8c54..b3f93ae 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -1,16 +1,61 @@ using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; +using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/discord")] -public class DiscordAuthController(Config config, DatabaseContext db) : ApiControllerBase +public class DiscordAuthController( + Config config, + ILogger logger, + IClock clock, + DatabaseContext db, + KeyCacheService keyCacheSvc, + AuthService authSvc, + RemoteAuthService remoteAuthSvc, + UserRendererService userRendererSvc) : ApiControllerBase { + [HttpPost("callback")] + public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) + { + CheckRequirements(); + await keyCacheSvc.ValidateAuthStateAsync(req.State); + + var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State); + var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); + if (user != null) return Ok(await GenerateUserTokenAsync(user)); + + logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, + remoteUser.Id); + + throw new NotImplementedException(); + } + + private async Task GenerateUserTokenAsync(User user) + { + var frontendApp = await db.GetFrontendApplicationAsync(); + logger.Debug("Logging user {Id} in with Discord", user.Id); + + var (tokenStr, token) = + authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + db.Add(token); + + logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); + + await db.SaveChangesAsync(); + + return new AuthController.AuthResponse( + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + tokenStr, + token.ExpiresAt + ); + } + private void CheckRequirements() { - if (config.DiscordAuth.ClientId == null || config.DiscordAuth.ClientSecret == null) - { + if (!config.DiscordAuth.Enabled) throw new ApiError.BadRequest("Discord authentication is not enabled on this instance."); - } } } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 3ba92ba..e1146c5 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -6,18 +6,31 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/email")] -public class EmailAuthController(DatabaseContext db, AuthService authSvc, UserRendererService userRendererSvc, IClock clock, ILogger logger) : ApiControllerBase +public class EmailAuthController( + DatabaseContext db, + AuthService authSvc, + UserRendererService userRendererSvc, + IClock clock, + ILogger logger) : ApiControllerBase { [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task LoginAsync([FromBody] LoginRequest req) { - var user = await authSvc.AuthenticateUserAsync(req.Email, req.Password); + var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password); + if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) + throw new NotImplementedException("MFA is not implemented yet"); + var frontendApp = await db.GetFrontendApplicationAsync(); - + + logger.Debug("Logging user {Id} in with email and password", user.Id); + var (tokenStr, token) = authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); db.Add(token); + logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); + await db.SaveChangesAsync(); return Ok(new AuthController.AuthResponse( diff --git a/Foxnouns.Backend/Controllers/DebugController.cs b/Foxnouns.Backend/Controllers/DebugController.cs index 3ef189b..94a0ff2 100644 --- a/Foxnouns.Backend/Controllers/DebugController.cs +++ b/Foxnouns.Backend/Controllers/DebugController.cs @@ -1,3 +1,4 @@ +using Foxnouns.Backend.Controllers.Authentication; using Foxnouns.Backend.Database; using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; @@ -6,10 +7,15 @@ using NodaTime; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/debug")] -public class DebugController(DatabaseContext db, AuthService authSvc, IClock clock, ILogger logger) : ApiControllerBase +public class DebugController( + DatabaseContext db, + AuthService authSvc, + UserRendererService userRendererSvc, + IClock clock, + ILogger logger) : ApiControllerBase { [HttpPost("users")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task CreateUserAsync([FromBody] CreateUserRequest req) { logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email); @@ -23,10 +29,12 @@ public class DebugController(DatabaseContext db, AuthService authSvc, IClock clo await db.SaveChangesAsync(); - return Ok(new AuthResponse(user.Id, user.Username, tokenStr)); + return Ok(new AuthController.AuthResponse( + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + tokenStr, + token.ExpiresAt + )); } public record CreateUserRequest(string Username, string Password, string Email); - - private record AuthResponse(Snowflake Id, string Username, string Token); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 451960e..f39810e 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -20,6 +20,9 @@ public class MetaController(DatabaseContext db) : ApiControllerBase ); } + [HttpGet("coffee")] + public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); + private record MetaResponse(string Version, string Hash, int Members, UserInfo Users); private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 158eb10..b03a9eb 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -67,7 +67,8 @@ public static class WebApplicationExtensions .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services .AddScoped() diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 0949a6f..69c8dc0 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -22,7 +22,11 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator { Id = snowflakeGenerator.GenerateSnowflake(), Username = username, - AuthMethods = { new AuthMethod { Id = snowflakeGenerator.GenerateSnowflake(), AuthType = AuthType.Email, RemoteId = email } } + AuthMethods = + { + new AuthMethod + { Id = snowflakeGenerator.GenerateSnowflake(), AuthType = AuthType.Email, RemoteId = email } + } }; db.Add(user); @@ -31,11 +35,21 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator return user; } - public async Task AuthenticateUserAsync(string email, string password) + /// + /// Authenticates a user with email and password. + /// + /// The user's email address + /// The user's password, in plain text + /// A tuple of the authenticated user and whether multi-factor authentication is required + /// Thrown if the email address is not associated with any user + /// or if the password is incorrect + public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password) { - var user = await db.Users.FirstOrDefaultAsync(u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email)); - if (user == null) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); - + var user = await db.Users.FirstOrDefaultAsync(u => + u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email)); + if (user == null) + throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); + var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password)); if (pwResult == PasswordVerificationResult.Failed) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); @@ -45,7 +59,36 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator await db.SaveChangesAsync(); } - return user; + return (user, EmailAuthenticationResult.AuthSuccessful); + } + + public enum EmailAuthenticationResult + { + AuthSuccessful, + MfaRequired, + } + + /// + /// Authenticates a user with a remote authentication provider. + /// + /// The remote authentication provider type + /// The remote user ID + /// The Fediverse instance, if authType is Fediverse. + /// Will throw an exception if passed with another authType. + /// A user object, or null if the remote account isn't linked to any user. + /// Thrown if instance is passed when not required, + /// or not passed when required + public async Task AuthenticateUserAsync(AuthType authType, string remoteId, + FediverseApplication? instance = null) + { + if (authType == AuthType.Fediverse && instance == null) + throw new FoxnounsError("Fediverse authentication requires an instance."); + if (authType != AuthType.Fediverse && instance != null) + throw new FoxnounsError("Non-Fediverse authentication does not require an instance."); + + return await db.Users.FirstOrDefaultAsync(u => + u.AuthMethods.Any(a => + a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance)); } public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 253cc9f..fabc316 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -48,4 +48,10 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) await SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); return state; } + + public async Task ValidateAuthStateAsync(string state) + { + var val = await GetKeyAsync($"oauth_state:{state}", delete: true); + if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs new file mode 100644 index 0000000..48d6b84 --- /dev/null +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using System.Web; + +namespace Foxnouns.Backend.Services; + +public class RemoteAuthService(Config config) +{ + private readonly HttpClient _httpClient = new(); + + private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); + private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); + + public async Task RequestDiscordTokenAsync(string code, string state) + { + var redirectUri = $"{config.BaseUrl}/auth/login/discord"; + var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( + new Dictionary + { + { "client_id", config.DiscordAuth.ClientId! }, + { "client_secret", config.DiscordAuth.ClientSecret! }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", redirectUri } + } + )); + resp.EnsureSuccessStatusCode(); + var token = await resp.Content.ReadFromJsonAsync(); + if (token == null) throw new FoxnounsError("Discord token response was null"); + + var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); + req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); + + var resp2 = await _httpClient.SendAsync(req); + resp2.EnsureSuccessStatusCode(); + var user = await resp2.Content.ReadFromJsonAsync(); + if (user == null) throw new FoxnounsError("Discord user response was null"); + + return new RemoteUser(user.id, user.username); + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + private record DiscordTokenResponse(string access_token, string token_type); + + [SuppressMessage("ReSharper", "InconsistentNaming")] + private record DiscordUserResponse(string id, string username); + + public record RemoteUser(string Id, string Username); +} \ No newline at end of file diff --git a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts new file mode 100644 index 0000000..2ebde3c --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts @@ -0,0 +1,10 @@ +import { fastRequest } from "$lib/request"; + +export const load = async ({ fetch, url }) => { + await fastRequest(fetch, "POST", "/auth/discord/callback", { + body: { + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, + }); +}; diff --git a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte new file mode 100644 index 0000000..3cb3fbc --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte @@ -0,0 +1,5 @@ + + +

omg its a login page