feat: initial fediverse registration/login

This commit is contained in:
sam 2024-11-03 02:07:07 +01:00
parent 5a22807410
commit c4cb08cdc1
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
16 changed files with 467 additions and 111 deletions

View file

@ -50,17 +50,6 @@ public class AuthController(
Instant ExpiresAt
);
public record CallbackResponse(
bool HasAccount,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? RemoteUsername,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
UserRendererService.UserResponse? User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
);
public record OauthRegisterRequest(string Ticket, string Username);
public record CallbackRequest(string Code, string State);
@ -77,3 +66,13 @@ public class AuthController(
return NoContent();
}
}
public record CallbackResponse(
bool HasAccount,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
UserRendererService.UserResponse? User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
);

View file

@ -2,6 +2,7 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
@ -14,20 +15,16 @@ namespace Foxnouns.Backend.Controllers.Authentication;
public class DiscordAuthController(
[UsedImplicitly] Config config,
ILogger logger,
IClock clock,
DatabaseContext db,
KeyCacheService keyCacheService,
AuthService authService,
RemoteAuthService remoteAuthService,
UserRendererService userRenderer
RemoteAuthService remoteAuthService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
[HttpPost("callback")]
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response
// leaving it here for documentation purposes
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req)
{
CheckRequirements();
@ -36,7 +33,7 @@ public class DiscordAuthController(
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
if (user != null)
return Ok(await GenerateUserTokenAsync(user));
return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug(
"Discord user {Username} ({Id}) authenticated with no local account",
@ -52,7 +49,7 @@ public class DiscordAuthController(
);
return Ok(
new AuthController.CallbackResponse(
new CallbackResponse(
HasAccount: false,
Ticket: ticket,
RemoteUsername: remoteUser.Username,
@ -94,42 +91,7 @@ public class DiscordAuthController(
remoteUser.Username
);
return Ok(await GenerateUserTokenAsync(user));
}
private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(
User user,
CancellationToken ct = default
)
{
var frontendApp = await db.GetFrontendApplicationAsync(ct);
_logger.Debug("Logging user {Id} in with Discord", user.Id);
var (tokenStr, token) = authService.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(ct);
return new AuthController.CallbackResponse(
HasAccount: true,
Ticket: null,
RemoteUsername: null,
User: await userRenderer.RenderUserAsync(
user,
selfUser: user,
renderMembers: false,
ct: ct
),
Token: tokenStr,
ExpiresAt: token.ExpiresAt
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
private void CheckRequirements()

View file

@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
@ -84,7 +85,7 @@ public class EmailAuthController(
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
return Ok(
new AuthController.CallbackResponse(
new CallbackResponse(
HasAccount: false,
Ticket: ticket,
RemoteUsername: state.Email,

View file

@ -1,11 +1,26 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/fediverse")]
public class FediverseAuthController(FediverseAuthService fediverseAuthService) : ApiControllerBase
public class FediverseAuthController(
ILogger logger,
DatabaseContext db,
FediverseAuthService fediverseAuthService,
AuthService authService,
KeyCacheService keyCacheService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
[HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
@ -14,12 +29,88 @@ public class FediverseAuthController(FediverseAuthService fediverseAuthService)
return Ok(new FediverseUrlResponse(url));
}
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
{
throw new NotImplementedException();
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
var user = await authService.AuthenticateUserAsync(
AuthType.Fediverse,
remoteUser.Id,
instance: app
);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
var ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync(
$"fediverse:{ticket}",
new FediverseTicketData(app.Id, remoteUser),
Duration.FromMinutes(20)
);
return Ok(
new CallbackResponse(
HasAccount: false,
Ticket: ticket,
RemoteUsername: $"@{remoteUser.Username}@{app.Domain}",
User: null,
Token: null,
ExpiresAt: null
)
);
}
[HttpPost("register")]
[ProducesResponseType<AuthController.AuthResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync(
[FromBody] AuthController.OauthRegisterRequest req
)
{
var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
$"fediverse:{req.Ticket}"
);
if (ticketData == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId);
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Fediverse
&& a.RemoteId == ticketData.User.Id
&& a.FediverseApplicationId == app.Id
)
)
{
_logger.Error(
"Fediverse user {Id}/{ApplicationId} ({Username} on {Domain}) has valid ticket but is already linked to an existing account",
ticketData.User.Id,
ticketData.ApplicationId,
ticketData.User.Username,
app.Domain
);
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
}
var user = await authService.CreateUserWithRemoteAuthAsync(
req.Username,
AuthType.Fediverse,
ticketData.User.Id,
ticketData.User.Username,
instance: app
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
public record CallbackRequest(string Instance, string Code);
private record FediverseUrlResponse(string Url);
private record FediverseTicketData(
Snowflake ApplicationId,
FediverseAuthService.FediverseUser User
);
}

View file

@ -4,12 +4,14 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Microsoft.EntityFrameworkCore;
using Minio;
using NodaTime;
using Prometheus;
using Serilog;
using Serilog.Events;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
using IClock = NodaTime.IClock;
namespace Foxnouns.Backend.Extensions;

View file

@ -1,4 +1,5 @@
using System.Security.Cryptography;
using Foxnouns.Backend.Controllers.Authentication;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
@ -6,10 +7,17 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Services.Auth;
public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator)
public class AuthService(
IClock clock,
ILogger logger,
DatabaseContext db,
ISnowflakeGenerator snowflakeGenerator,
UserRendererService userRenderer
)
{
private readonly ILogger _logger = logger.ForContext<AuthService>();
private readonly PasswordHasher<User> _passwordHasher = new();
/// <summary>
@ -256,6 +264,45 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
);
}
/// <summary>
/// Generates a token for the given user and adds it to the database, returning a fully formed auth response for the user.
/// This method is always called at the end of an endpoint method, so the resulting token
/// (and user, if this is a registration request) is also saved to the database.
/// </summary>
public async Task<CallbackResponse> GenerateUserTokenAsync(
User user,
CancellationToken ct = default
)
{
var frontendApp = await db.GetFrontendApplicationAsync(ct);
var (tokenStr, token) = 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(ct);
return new CallbackResponse(
HasAccount: true,
Ticket: null,
RemoteUsername: null,
User: await userRenderer.RenderUserAsync(
user,
selfUser: user,
renderMembers: false,
ct: ct
),
Token: tokenStr,
ExpiresAt: token.ExpiresAt
);
}
private static (string, byte[]) GenerateToken()
{
var token = AuthUtils.RandomToken();

View file

@ -5,12 +5,12 @@ using Foxnouns.Backend.Database.Models;
using Duration = NodaTime.Duration;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Services.Auth;
public partial class FediverseAuthService
{
private string MastodonRedirectUri(string instance) =>
$"{_config.BaseUrl}/auth/login/mastodon/{instance}";
$"{_config.BaseUrl}/auth/callback/mastodon/{instance}";
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
string instance,

View file

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using NodaTime;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Services.Auth;
public partial class FediverseAuthService
{
@ -43,12 +43,6 @@ public partial class FediverseAuthService
return await GenerateAuthUrlAsync(app);
}
public async Task<FediverseUser> GetRemoteFediverseUserAsync(string instance, string code)
{
var app = await GetApplicationAsync(instance);
return await GetRemoteUserAsync(app, code);
}
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
// and having both mastodon and misskey use "username" in the self user response
public record FediverseUser(
@ -56,7 +50,7 @@ public partial class FediverseAuthService
[property: J("username")] string Username
);
private async Task<FediverseApplication> GetApplicationAsync(string instance)
public async Task<FediverseApplication> GetApplicationAsync(string instance)
{
var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
if (app != null)
@ -110,7 +104,10 @@ public partial class FediverseAuthService
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
};
private async Task<FediverseUser> GetRemoteUserAsync(FediverseApplication app, string code) =>
public async Task<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app,
string code
) =>
app.InstanceType switch
{
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),

View file

@ -97,7 +97,7 @@ public class UserRendererService(
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
: null;
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
public record UserResponse(
Snowflake Id,