// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using System.Net; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/internal/auth/fediverse")] [ApiExplorerSettings(IgnoreApi = true)] public class FediverseAuthController( ILogger logger, DatabaseContext db, FediverseAuthService fediverseAuthService, AuthService authService, RemoteAuthService remoteAuthService, KeyCacheService keyCacheService ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpGet] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync( [FromQuery] string instance, [FromQuery(Name = "force-refresh")] bool forceRefresh = false ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); return Ok(new SingleUrlResponse(url)); } [HttpPost("callback")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task FediverseCallbackAsync([FromBody] FediverseCallbackRequest req) { FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseAuthService.FediverseUser remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code, req.State); User? user = await authService.AuthenticateUserAsync( AuthType.Fediverse, remoteUser.Id, app ); if (user != null) return Ok(await authService.GenerateUserTokenAsync(user)); string ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"fediverse:{ticket}", new FediverseTicketData(app.Id, remoteUser), Duration.FromMinutes(20) ); return Ok( new CallbackResponse( false, ticket, $"@{remoteUser.Username}@{app.Domain}", null, null, null ) ); } [HttpPost("register")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task RegisterAsync([FromBody] OauthRegisterRequest req) { FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync( $"fediverse:{req.Ticket}", true ); if (ticketData == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); FediverseApplication? app = await db.FediverseApplications.FindAsync( ticketData.ApplicationId ); if (app == null) throw new FoxnounsError("Null application found for ticket"); 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); } User user = await authService.CreateUserWithRemoteAuthAsync( req.Username, AuthType.Fediverse, ticketData.User.Id, ticketData.User.Username, app ); return Ok(await authService.GenerateUserTokenAsync(user)); } [HttpGet("add-account")] [Authorize("*")] public async Task AddFediverseAccountAsync( [FromQuery] string instance, [FromQuery(Name = "force-refresh")] bool forceRefresh = false ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); string state = await remoteAuthService.ValidateAddAccountRequestAsync( CurrentUser!.Id, AuthType.Fediverse, instance ); string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state); return Ok(new SingleUrlResponse(url)); } [HttpPost("add-account/callback")] [Authorize("*")] public async Task AddAccountCallbackAsync( [FromBody] FediverseCallbackRequest req ) { FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseAuthService.FediverseUser remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); try { AuthMethod authMethod = await authService.AddAuthMethodAsync( CurrentUser!.Id, AuthType.Fediverse, remoteUser.Id, remoteUser.Username, app ); _logger.Debug( "Added new Fediverse auth method {AuthMethodId} to user {UserId}", authMethod.Id, CurrentUser.Id ); return Ok( new AddOauthAccountResponse( authMethod.Id, AuthType.Fediverse, authMethod.RemoteId, $"{authMethod.RemoteUsername}@{app.Domain}" ) ); } catch (UniqueConstraintException) { throw new ApiError( "That account is already linked.", HttpStatusCode.BadRequest, ErrorCode.AccountAlreadyLinked ); } } private record FediverseTicketData( Snowflake ApplicationId, FediverseAuthService.FediverseUser User ); }