using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using PrivaPub.Api.Mastodon.Auth; using PrivaPub.ClientModels; using PrivaPub.Federation.Actors; using PrivaPub.Infrastructure; using PrivaPub.Models.Federation; using PrivaPub.Models.User; using PrivaPub.Services; using PrivaPub.StaticServices; using System.Security.Claims; using static OpenIddict.Abstractions.OpenIddictConstants; namespace PrivaPub.Web.Pages.OAuth { [EnableRateLimiting(RateLimiting.Accounts)] public class LoginModel : PageModel { readonly IRootUsersService _users; public LoginModel(IRootUsersService users) { _users = users; } [BindProperty(SupportsGet = true)] public string ReturnUrl { get; set; } public string Error { get; private set; } public void OnGet() => Harden(); public async Task OnPostAsync([FromForm] string userName, [FromForm] string password) { Harden(); if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password)) { Error = "Enter your username and password."; return Page(); } var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password }); if (!result.IsValid) { Error = "That username and password do not match."; return Page(); } var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data; var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, root.ID) }, OAuthSetup.LoginScheme); await HttpContext.SignInAsync(OAuthSetup.LoginScheme, new ClaimsPrincipal(identity)); var target = ReturnUrl?.StartsWith("/oauth/authorize?", StringComparison.Ordinal) == true ? ReturnUrl : "/oauth/authorize"; return LocalRedirect(target + (target.Contains('?') ? "&" : "?") + "signed_in=1"); } void Harden() { Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"; Response.Headers["Cache-Control"] = "no-store"; } } [IgnoreAntiforgeryToken] public class AuthorizeModel : PageModel { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly OpenIddict.Abstractions.IOpenIddictApplicationManager _applications; public AuthorizeModel(DbEntities dbEntities, ILocalActorService localActors, OpenIddict.Abstractions.IOpenIddictApplicationManager applications) { _dbEntities = dbEntities; _localActors = localActors; _applications = applications; } public string ApplicationName { get; private set; } public IReadOnlyList RequestedScopes { get; private set; } = Array.Empty(); public IReadOnlyList Avatars { get; private set; } = Array.Empty(); public IReadOnlyList> Parameters { get; private set; } = Array.Empty>(); public async Task OnGetAsync(CancellationToken token) => await Show(token); public async Task OnPostAsync([FromForm] string avatarId, [FromForm] string decision, CancellationToken token) { var request = HttpContext.GetOpenIddictServerRequest(); var rootId = await SignedInRoot(); if (request == default || rootId == default) return await Show(token); if (decision != "allow") return Forbid(new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the request." }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); var owns = !string.IsNullOrEmpty(avatarId) && await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token); var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default; if (avatar == default) return await Show(token); var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); identity.SetClaim(Claims.Subject, avatar.Id); identity.SetClaim(Claims.Name, avatar.UserName); identity.SetScopes(MastodonScopes.Parse(request.Scope)); identity.SetDestinations(_ => new[] { Destinations.AccessToken }); await HttpContext.SignOutAsync(OAuthSetup.LoginScheme); return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } async Task Show(CancellationToken token) { var request = HttpContext.GetOpenIddictServerRequest(); if (request == default) return BadRequest(); var rootId = await SignedInRoot(); var forceLogin = string.Equals((string)request["force_login"], "true", StringComparison.OrdinalIgnoreCase) && Request.Query["signed_in"] != "1"; if (rootId == default || forceLogin) { var query = string.Join("&", Request.Query.Where(q => q.Key != "signed_in").Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value.ToString())}")); return Redirect($"/oauth/login?returnUrl={Uri.EscapeDataString("/oauth/authorize?" + query)}"); } var application = await _applications.FindByClientIdAsync(request.ClientId, token); ApplicationName = application == default ? "an application" : await _applications.GetDisplayNameAsync(application, token) ?? "an application"; RequestedScopes = MastodonScopes.Parse(request.Scope); var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList(); var avatars = new List(); foreach (var id in avatarIds) if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar) avatars.Add(avatar); Avatars = avatars; Parameters = request.GetParameters() .Where(p => p.Key is not ("avatarId" or "decision" or "signed_in")) .Select(p => new KeyValuePair(p.Key, (string)p.Value)) .ToList(); Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"; Response.Headers["Cache-Control"] = "no-store"; return Page(); } async Task SignedInRoot() { var login = await HttpContext.AuthenticateAsync(OAuthSetup.LoginScheme); var rootId = login.Succeeded ? login.Principal.FindFirstValue(ClaimTypes.NameIdentifier) : default; if (rootId == default) return default; var root = await _dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync(); return root is { IsBanned: false, DeletedAt: null } ? rootId : default; } } }