// 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 System.Web; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; 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; using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/internal/auth/google")] public class GoogleAuthController( [UsedImplicitly] Config config, ILogger logger, DatabaseContext db, KeyCacheService keyCacheService, AuthService authService, RemoteAuthService remoteAuthService ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpPost("callback")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task CallbackAsync([FromBody] CallbackRequest req) { CheckRequirements(); await keyCacheService.ValidateAuthStateAsync(req.State); RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync( req.Code ); User? user = await authService.AuthenticateUserAsync(AuthType.Google, remoteUser.Id); if (user != null) return Ok(await authService.GenerateUserTokenAsync(user)); _logger.Debug( "Google user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id ); string ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync($"google:{ticket}", remoteUser, Duration.FromMinutes(20)); return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null)); } [HttpPost("register")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task RegisterAsync([FromBody] OauthRegisterRequest req) { RemoteAuthService.RemoteUser? remoteUser = await keyCacheService.GetKeyAsync($"google:{req.Ticket}"); if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); if ( await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Google && a.RemoteId == remoteUser.Id ) ) { _logger.Error( "Google user {Id} has valid ticket but is already linked to an existing account", remoteUser.Id ); throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); } User user = await authService.CreateUserWithRemoteAuthAsync( req.Username, AuthType.Google, remoteUser.Id, remoteUser.Username ); return Ok(await authService.GenerateUserTokenAsync(user)); } [HttpGet("add-account")] [Authorize("*")] public async Task AddGoogleAccountAsync() { CheckRequirements(); string state = await remoteAuthService.ValidateAddAccountRequestAsync( CurrentUser!.Id, AuthType.Google ); string url = "https://accounts.google.com/o/oauth2/auth?response_type=code" + $"&client_id={config.GoogleAuth.ClientId}" + $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}" + $"&prompt=select_account&state={state}" + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}"; return Ok(new SingleUrlResponse(url)); } [HttpPost("add-account/callback")] [Authorize("*")] public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) { CheckRequirements(); await remoteAuthService.ValidateAddAccountStateAsync( req.State, CurrentUser!.Id, AuthType.Google ); RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync( req.Code ); try { AuthMethod authMethod = await authService.AddAuthMethodAsync( CurrentUser.Id, AuthType.Google, remoteUser.Id, remoteUser.Username ); _logger.Debug( "Added new Google auth method {AuthMethodId} to user {UserId}", authMethod.Id, CurrentUser.Id ); return Ok( new AddOauthAccountResponse( authMethod.Id, AuthType.Google, authMethod.RemoteId, authMethod.RemoteUsername ) ); } catch (UniqueConstraintException) { throw new ApiError( "That account is already linked.", HttpStatusCode.BadRequest, ErrorCode.AccountAlreadyLinked ); } } private void CheckRequirements() { if (!config.GoogleAuth.Enabled) { throw new ApiError.BadRequest("Google authentication is not enabled on this instance."); } } }