diff --git a/PrivaPub/Api/Mastodon/Auth/MastodonScopes.cs b/PrivaPub/Api/Mastodon/Auth/MastodonScopes.cs new file mode 100644 index 0000000..07a06e3 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Auth/MastodonScopes.cs @@ -0,0 +1,37 @@ +namespace PrivaPub.Api.Mastodon.Auth +{ + public static class MastodonScopes + { + public static readonly string[] All = + { + "read", "write", "follow", "push", "profile", + "read:accounts", "read:blocks", "read:bookmarks", "read:favourites", "read:filters", "read:follows", "read:lists", + "read:mutes", "read:notifications", "read:search", "read:statuses", + "write:accounts", "write:blocks", "write:bookmarks", "write:conversations", "write:favourites", "write:filters", + "write:follows", "write:lists", "write:media", "write:mutes", "write:notifications", "write:reports", "write:statuses" + }; + + public static IReadOnlyList Parse(string scopes) + { + var parsed = (scopes ?? string.Empty).Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries) + .Where(s => All.Contains(s)) + .Distinct() + .ToList(); + return parsed.Count == 0 ? new[] { "read" } : parsed; + } + + public static bool Grants(IEnumerable granted, string required) + { + var scopes = granted as ICollection ?? granted.ToList(); + if (scopes.Contains(required)) + return true; + var colon = required.IndexOf(':'); + if (colon > 0 && scopes.Contains(required[..colon])) + return true; + return required.StartsWith("read:follows") || required.StartsWith("write:follows") || required.StartsWith("write:blocks") + || required.StartsWith("read:blocks") || required.StartsWith("write:mutes") || required.StartsWith("read:mutes") + ? scopes.Contains("follow") + : false; + } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs new file mode 100644 index 0000000..e2e0456 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using MongoDB.Driver; +using MongoDB.Entities; + +using OpenIddict.Abstractions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; + +using PrivaPub.Models; + +using System.Net; + +using static OpenIddict.Server.OpenIddictServerEvents; + +namespace PrivaPub.Api.Mastodon.Auth +{ + public static class OAuthSetup + { + public const string LoginScheme = "PrivaPub.OAuth"; + public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob"; + + public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment) + { + services.AddSingleton(_ => DB.Default.Database()); + + services.AddAuthentication() + .AddCookie(LoginScheme, options => + { + options.Cookie.Name = "privapub.oauth"; + options.Cookie.Path = "/oauth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromMinutes(15); + options.SlidingExpiration = false; + options.LoginPath = "/oauth/login"; + }); + + services.AddOpenIddict() + .AddCore(options => options.UseMongoDb()) + .AddServer(options => + { + options.SetAuthorizationEndpointUris("/oauth/authorize") + .SetTokenEndpointUris("/oauth/token") + .SetRevocationEndpointUris("/oauth/revoke") + .SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server"); + options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow(); + options.RegisterScopes(MastodonScopes.All); + options.UseReferenceAccessTokens(); + options.SetAccessTokenLifetime(null); + options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10)); + options.DisableAccessTokenEncryption(); + + var aspNetCore = options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough(); + if (!environment.IsProduction()) + aspNetCore.DisableTransportSecurityRequirement(); + + options.AddEventHandler(builder => builder.UseInlineHandler(OutOfBandCode) + .SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000) + .SetType(OpenIddictServerHandlerType.Custom)); + options.AddEventHandler(builder => builder.UseInlineHandler(context => + { + if (context.Response.AccessToken != default) + context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return default; + })); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + + services.AddOptions().Configure>((options, app) => + { + options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/"); + options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256)); + options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption), + SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)); + }); + return services; + } + + static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context) + { + if (context.RedirectUri != OutOfBand || context.Response.Code == default) + return; + var http = context.Transaction.GetHttpRequest()?.HttpContext; + if (http == default) + return; + var code = WebUtility.HtmlEncode(context.Response.Code); + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "text/html; charset=utf-8"; + http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'"; + http.Response.Headers["Cache-Control"] = "no-store"; + await http.Response.WriteAsync($$""" + + Authorization code +

Authorization code

Copy this code into the application:

{{code}} + """); + context.HandleRequest(); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/OidcKey.cs b/PrivaPub/Api/Mastodon/Auth/OidcKey.cs new file mode 100644 index 0000000..511a267 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Auth/OidcKey.cs @@ -0,0 +1,33 @@ +using Microsoft.IdentityModel.Tokens; + +using MongoDB.Entities; + +using System.Security.Cryptography; + +namespace PrivaPub.Api.Mastodon.Auth +{ + public class OidcKey : Entity + { + public string Use { get; set; } + public string PrivateKeyPem { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public const string Signing = "signing"; + public const string Encryption = "encryption"; + + public static RsaSecurityKey Load(string use) + { + var key = DB.Default.Find().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult(); + if (key == default) + { + using var created = RSA.Create(2048); + key = new OidcKey { Use = use, PrivateKeyPem = created.ExportRSAPrivateKeyPem() }; + DB.Default.SaveAsync(key).GetAwaiter().GetResult(); + key = DB.Default.Find().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult(); + } + var rsa = RSA.Create(); + rsa.ImportFromPem(key.PrivateKeyPem); + return new RsaSecurityKey(rsa) { KeyId = key.ID }; + } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/TokenController.cs b/PrivaPub/Api/Mastodon/Auth/TokenController.cs new file mode 100644 index 0000000..aa6990b --- /dev/null +++ b/PrivaPub/Api/Mastodon/Auth/TokenController.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; + +using PrivaPub.StaticServices; + +using System.Security.Claims; + +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace PrivaPub.Api.Mastodon.Auth +{ + [ApiController] + public class TokenController : ControllerBase + { + readonly DbEntities _dbEntities; + + public TokenController(DbEntities dbEntities) + { + _dbEntities = dbEntities; + } + + [HttpPost("/oauth/token"), IgnoreAntiforgeryToken, Produces("application/json")] + public async Task Exchange(CancellationToken token) + { + var request = HttpContext.GetOpenIddictServerRequest(); + if (request == default) + return BadRequest(); + + if (request.IsAuthorizationCodeGrantType()) + { + var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal; + if (principal == default || !await Usable(principal.GetClaim(Claims.Subject), token)) + return Refuse(Errors.InvalidGrant, "The persona can no longer be used."); + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (request.IsClientCredentialsGrantType()) + { + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, "app:" + request.ClientId); + identity.SetScopes(MastodonScopes.Parse(request.Scope)); + identity.SetDestinations(_ => new[] { Destinations.AccessToken }); + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported."); + } + + async Task Usable(string avatarId, CancellationToken token) + { + if (string.IsNullOrEmpty(avatarId)) + return false; + var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token); + var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); + var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token); + return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null }; + } + + ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs new file mode 100644 index 0000000..7f51fb9 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public partial class AccountsController : MastodonController + { + readonly MastodonMapper _mapper; + + public AccountsController(MastodonMapper mapper) + { + _mapper = mapper; + } + + [HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")] + public async Task VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token)); + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AppsController.cs b/PrivaPub/Api/Mastodon/Controllers/AppsController.cs new file mode 100644 index 0000000..a323766 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/AppsController.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using OpenIddict.Abstractions; + +using PrivaPub.Api.Mastodon.Auth; +using PrivaPub.Api.Mastodon.Entities; +using PrivaPub.Api.Mastodon.Infrastructure; + +using System.Security.Cryptography; + +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class AppsController : MastodonController + { + readonly IOpenIddictApplicationManager _applications; + + public AppsController(IOpenIddictApplicationManager applications) + { + _applications = applications; + } + + [HttpPost("/api/v1/apps"), AllowAnonymous] + public async Task Create(CancellationToken token) + { + var name = Params.Get("client_name")?.Trim(); + if (string.IsNullOrEmpty(name) || name.Length > 200) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank"); + + var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList(); + if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data")) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI."); + + var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes"))); + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = Secret(32), + ClientSecret = Secret(32), + ClientType = ClientTypes.Confidential, + ConsentType = ConsentTypes.Explicit, + DisplayName = name, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.Endpoints.Revocation, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.ClientCredentials, + Permissions.ResponseTypes.Code + } + }; + foreach (var scope in scopes) + descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope); + foreach (var redirect in redirects) + descriptor.RedirectUris.Add(new Uri(redirect)); + var website = Params.Get("website"); + if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http") + descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website); + + var application = await _applications.CreateAsync(descriptor, token); + return Json(new Application + { + Id = await _applications.GetIdAsync(application, token), + Name = name, + Website = website, + Scopes = scopes.ToList(), + RedirectUris = redirects, + RedirectUri = string.Join("\n", redirects), + ClientId = descriptor.ClientId, + ClientSecret = descriptor.ClientSecret + }); + } + + [HttpGet("/api/v1/apps/verify_credentials")] + public async Task Verify(CancellationToken token) + { + var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId); + var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token); + if (application == default) + return Error(StatusCodes.Status401Unauthorized, "The access token is invalid"); + var properties = await _applications.GetPropertiesAsync(application, token); + return Json(new + { + id = await _applications.GetIdAsync(application, token), + name = await _applications.GetDisplayNameAsync(application, token), + website = properties.TryGetValue("website", out var website) ? website.GetString() : default, + scopes = User.GetScopes().ToList(), + redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(), + vapid_key = string.Empty + }); + } + + static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs new file mode 100644 index 0000000..4032f9c --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Domain.Privacy; +using PrivaPub.Federation.Actors; +using PrivaPub.Models.User; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class InstanceController : MastodonController + { + public const string Version = "4.2.0 (compatible; PrivaPub)"; + const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas."; + + readonly ILocalActorService _localActors; + + public InstanceController(ILocalActorService localActors) + { + _localActors = localActors; + } + + string Domain => new Uri(_localActors.BaseAddress).Authority; + + static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 }; + + static object MediaAttachments => new + { + supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" }, + image_size_limit = 16 * 1024 * 1024, + image_matrix_limit = 16_777_216, + video_size_limit = 99 * 1024 * 1024, + video_frame_rate_limit = 60, + video_matrix_limit = 2_304_000 + }; + + static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 }; + + [HttpGet("/api/v1/instance"), AllowAnonymous] + public async Task V1(CancellationToken token) + { + var users = await DB.Default.CountAsync(a => !a.DeletionAt.HasValue, token); + var statuses = await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token); + var domains = (await DB.Default.Find().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count(); + return Json(new + { + uri = Domain, + title = "PrivaPub", + short_description = Description, + description = Description, + email = string.Empty, + version = Version, + urls = new { streaming_api = $"wss://{Domain}" }, + stats = new { user_count = users, status_count = statuses, domain_count = domains }, + thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png", + languages = new[] { "en" }, + registrations = false, + approval_required = false, + invites_enabled = false, + configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls }, + contact_account = default(object), + rules = Array.Empty() + }); + } + + [HttpGet("/api/v2/instance"), AllowAnonymous] + public IActionResult V2() => Json(new + { + domain = Domain, + title = "PrivaPub", + version = Version, + source_url = "https://git.thepra.dev/thepra/SocialPub", + description = Description, + usage = new { users = new { active_month = 0 } }, + thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" }, + languages = new[] { "en" }, + configuration = new + { + urls = new { streaming = $"wss://{Domain}" }, + accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 }, + statuses = Statuses, + media_attachments = MediaAttachments, + polls = Polls, + translation = new { enabled = false } + }, + registrations = new { enabled = false, approval_required = false, message = default(string) }, + contact = new { email = string.Empty, account = default(object) }, + rules = Array.Empty() + }); + + [HttpGet("/api/v1/instance/peers"), AllowAnonymous] + public IActionResult Peers() => Json(Array.Empty()); + + [HttpGet("/api/v1/instance/activity"), AllowAnonymous] + public IActionResult Activity() => Json(Array.Empty()); + + [HttpGet("/api/v1/instance/rules"), AllowAnonymous] + public IActionResult Rules() => Json(Array.Empty()); + + [HttpGet("/api/v1/instance/extended_description"), AllowAnonymous] + public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"

{Description}

" }); + } +} diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs new file mode 100644 index 0000000..4449160 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -0,0 +1,200 @@ +namespace PrivaPub.Api.Mastodon.Entities +{ + public class Account + { + public string Id { get; set; } + public string Username { get; set; } + public string Acct { get; set; } + public string DisplayName { get; set; } + public bool Locked { get; set; } + public bool Bot { get; set; } + public bool? Discoverable { get; set; } + public bool Indexable { get; set; } + public bool Group { get; set; } + public string CreatedAt { get; set; } + public string Note { get; set; } + public string Url { get; set; } + public string Uri { get; set; } + public string Avatar { get; set; } + public string AvatarStatic { get; set; } + public string Header { get; set; } + public string HeaderStatic { get; set; } + public int FollowersCount { get; set; } + public int FollowingCount { get; set; } + public int StatusesCount { get; set; } + public string LastStatusAt { get; set; } + public bool? HideCollections { get; set; } + public bool Noindex { get; set; } = true; + public List Emojis { get; set; } = new(); + public List Roles { get; set; } = new(); + public List Fields { get; set; } = new(); + public Source Source { get; set; } + } + + public class Field + { + public string Name { get; set; } + public string Value { get; set; } + public string VerifiedAt { get; set; } + } + + public class Source + { + public string Privacy { get; set; } = "public"; + public bool Sensitive { get; set; } + public string Language { get; set; } + public string Note { get; set; } = string.Empty; + public List Fields { get; set; } = new(); + public int FollowRequestsCount { get; set; } + public string AttributionDomains { get; set; } + } + + public class Status + { + public string Id { get; set; } + public string Uri { get; set; } + public string Url { get; set; } + public Account Account { get; set; } + public string InReplyToId { get; set; } + public string InReplyToAccountId { get; set; } + public Status Reblog { get; set; } + public string Content { get; set; } = string.Empty; + public string CreatedAt { get; set; } + public string EditedAt { get; set; } + public List Emojis { get; set; } = new(); + public int RepliesCount { get; set; } + public int ReblogsCount { get; set; } + public int FavouritesCount { get; set; } + public bool Reblogged { get; set; } + public bool Favourited { get; set; } + public bool Muted { get; set; } + public bool Bookmarked { get; set; } + public bool Pinned { get; set; } + public bool Sensitive { get; set; } + public string SpoilerText { get; set; } = string.Empty; + public string Visibility { get; set; } + public List MediaAttachments { get; set; } = new(); + public List Mentions { get; set; } = new(); + public List Tags { get; set; } = new(); + public object Card { get; set; } + public object Poll { get; set; } + public Application Application { get; set; } + public string Language { get; set; } + public string Text { get; set; } + public List Filtered { get; set; } = new(); + } + + public class MediaAttachment + { + public string Id { get; set; } + public string Type { get; set; } + public string Url { get; set; } + public string PreviewUrl { get; set; } + public string RemoteUrl { get; set; } + public object Meta { get; set; } + public string Description { get; set; } + public string Blurhash { get; set; } + } + + public class StatusMention + { + public string Id { get; set; } + public string Username { get; set; } + public string Url { get; set; } + public string Acct { get; set; } + } + + public class StatusTag + { + public string Name { get; set; } + public string Url { get; set; } + } + + public class Application + { + public string Id { get; set; } + public string Name { get; set; } + public string Website { get; set; } + public List Scopes { get; set; } = new(); + public List RedirectUris { get; set; } = new(); + public string RedirectUri { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string VapidKey { get; set; } = string.Empty; + } + + public class Relationship + { + public string Id { get; set; } + public bool Following { get; set; } + public bool ShowingReblogs { get; set; } + public bool Notifying { get; set; } + public List Languages { get; set; } + public bool FollowedBy { get; set; } + public bool Blocking { get; set; } + public bool BlockedBy { get; set; } + public bool Muting { get; set; } + public bool MutingNotifications { get; set; } + public bool Requested { get; set; } + public bool RequestedBy { get; set; } + public bool DomainBlocking { get; set; } + public bool Endorsed { get; set; } + public string Note { get; set; } = string.Empty; + } + + public class Notification + { + public string Id { get; set; } + public string Type { get; set; } + public string CreatedAt { get; set; } + public string GroupKey { get; set; } + public Account Account { get; set; } + public Status Status { get; set; } + } + + public class Context + { + public List Ancestors { get; set; } = new(); + public List Descendants { get; set; } = new(); + } + + public class StatusEdit + { + public string Content { get; set; } + public string SpoilerText { get; set; } + public bool Sensitive { get; set; } + public string CreatedAt { get; set; } + public Account Account { get; set; } + public List MediaAttachments { get; set; } = new(); + public List Emojis { get; set; } = new(); + } + + public class StatusSource + { + public string Id { get; set; } + public string Text { get; set; } + public string SpoilerText { get; set; } + } + + public class SearchResults + { + public List Accounts { get; set; } = new(); + public List Statuses { get; set; } = new(); + public List Hashtags { get; set; } = new(); + } + + public class Conversation + { + public string Id { get; set; } + public bool Unread { get; set; } + public List Accounts { get; set; } = new(); + public Status LastStatus { get; set; } + } + + public class Marker + { + public string LastReadId { get; set; } + public int Version { get; set; } + public string UpdatedAt { get; set; } + } +} diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs new file mode 100644 index 0000000..64ee62c --- /dev/null +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +using MongoDB.Entities; + +using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; + +using PrivaPub.Api.Mastodon.Auth; +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Federation; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace PrivaPub.Api.Mastodon.Infrastructure +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public sealed class ScopeAttribute : Attribute + { + public ScopeAttribute(string scope, bool requiresUser = true) + { + Scope = scope; + RequiresUser = requiresUser; + } + + public string Scope { get; } + public bool RequiresUser { get; } + } + + [ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] + public abstract class MastodonController : ControllerBase, IAsyncActionFilter + { + MastodonParams _params; + + protected LocalActor Me { get; private set; } + + protected string MyId => Me?.Id; + + protected MastodonParams Params => _params; + + [NonAction] + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + _params = await MastodonParams.Read(Request, HttpContext.RequestAborted); + var scope = context.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault(); + if (User.Identity?.IsAuthenticated == true) + { + if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope)) + { + context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes"); + return; + } + Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted); + } + if (scope?.RequiresUser == true && Me == default) + { + context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user"); + return; + } + await next(); + } + + async Task Resolve(string avatarId, CancellationToken token) + { + if (string.IsNullOrEmpty(avatarId)) + return default; + var services = HttpContext.RequestServices; + var db = services.GetRequiredService(); + var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token); + if (link == default) + return default; + var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); + if (root is not { IsBanned: false, DeletedAt: null }) + return default; + return await services.GetRequiredService().FindById(LocalActorKind.Person, avatarId, token); + } + + protected JsonResult Json(object value, int status = StatusCodes.Status200OK) => + new(value, MastodonJson.Options) { StatusCode = status }; + + protected static JsonResult Error(int status, string message) => + new(new { error = message }, MastodonJson.Options) { StatusCode = status }; + + protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found"); + + protected void Link(string path, string maxId, string minId) + { + var links = new List(); + var baseUrl = $"{Request.Scheme}://{Request.Host}{path}"; + var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList(); + string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}")); + if (maxId != default) + links.Add($"<{With("max_id", maxId)}>; rel=\"next\""); + if (minId != default) + links.Add($"<{With("min_id", minId)}>; rel=\"prev\""); + if (links.Count > 0) + Response.Headers["Link"] = string.Join(", ", links); + } + + protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max); + } +} diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonJson.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonJson.cs new file mode 100644 index 0000000..1e0bdd6 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonJson.cs @@ -0,0 +1,24 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PrivaPub.Api.Mastodon.Infrastructure +{ + public static class MastodonJson + { + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = default, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + public static string Time(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture); + + public static string Day(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs new file mode 100644 index 0000000..7a43915 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Http.Features; + +using System.Text.Json; + +namespace PrivaPub.Api.Mastodon.Infrastructure +{ + public sealed class MastodonParams + { + readonly Dictionary> _values; + + MastodonParams(Dictionary> values) + { + _values = values; + } + + public static async Task Read(HttpRequest request, CancellationToken token) + { + var values = new Dictionary>(StringComparer.Ordinal); + foreach (var pair in request.Query) + foreach (var value in pair.Value) + Add(values, pair.Key, value); + + if (request.HasFormContentType) + { + var form = await request.ReadFormAsync(token); + foreach (var pair in form) + foreach (var value in pair.Value) + Add(values, pair.Key, value); + } + else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0) + { + try + { + using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token); + Flatten(values, default, document.RootElement); + } + catch (JsonException) + { + } + } + return new MastodonParams(values); + } + + public static MastodonParams From(IEnumerable> pairs) + { + var values = new Dictionary>(StringComparer.Ordinal); + foreach (var pair in pairs) + Add(values, pair.Key, pair.Value); + return new MastodonParams(values); + } + + public bool Has(string name) => _values.ContainsKey(name); + + public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default; + + public IReadOnlyList List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList)Array.Empty(); + + public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch + { + "true" or "1" or "on" or "yes" => true, + "false" or "0" or "off" or "no" or "" => false, + _ => default + }; + + public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : default; + + static void Add(Dictionary> values, string key, string value) + { + key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key; + if (!values.TryGetValue(key, out var list)) + values[key] = list = new List(); + list.Add(value); + } + + static void Flatten(Dictionary> values, string prefix, JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value); + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + Flatten(values, prefix + "[]", item); + else + Add(values, prefix, Scalar(item)); + } + break; + case JsonValueKind.Null or JsonValueKind.Undefined: + break; + default: + if (prefix != default) + Add(values, prefix, Scalar(element)); + break; + } + } + + static string Scalar(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => element.GetRawText() + }; + } +} diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs new file mode 100644 index 0000000..8fad356 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -0,0 +1,249 @@ +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Entities; +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Net; + +using GroupEntity = PrivaPub.Models.Group.Group; +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Api.Mastodon.Mappers +{ + public class MastodonMapper + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + + public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + } + + string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png"; + string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png"; + + public async Task Local(LocalActor actor, bool withSource, CancellationToken token) + { + var followers = await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token); + var following = actor.Kind == LocalActorKind.Person + ? await DB.Default.CountAsync(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token) + : 0; + var latest = await _dbEntities.Posts + .Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy + && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null) + .Sort(p => p.ID, Order.Descending) + .Limit(1) + .ExecuteFirstAsync(token); + var statuses = latest == default + ? 0 + : await DB.Default.CountAsync(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) + && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token); + + var account = new Account + { + Id = actor.Id, + Username = actor.UserName, + Acct = actor.UserName, + DisplayName = actor.Name ?? actor.UserName, + Locked = actor.ManuallyApprovesFollowers, + Discoverable = actor.Discoverable, + Group = actor.Kind == LocalActorKind.Group, + CreatedAt = MastodonJson.Day(actor.Published), + Note = ActivityPubRenderer.Html(actor.Summary), + Url = actor.HtmlUrl, + Uri = actor.Uri, + Avatar = actor.PictureURL ?? MissingAvatar, + AvatarStatic = actor.PictureURL ?? MissingAvatar, + Header = actor.ThumbnailURL ?? MissingHeader, + HeaderStatic = actor.ThumbnailURL ?? MissingHeader, + FollowersCount = (int)followers, + FollowingCount = (int)following, + StatusesCount = (int)statuses, + LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"), + Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList() + }; + if (withSource) + account.Source = new Source + { + Note = actor.Summary ?? string.Empty, + Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), + FollowRequestsCount = (int)await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && !f.IsAccepted, token) + }; + return account; + } + + public Account Foreign(ForeignAvatar foreign) => new() + { + Id = foreign.ID, + Username = foreign.UserName, + Acct = $"{foreign.UserName}@{foreign.Domain}", + DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty, + Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application, + Group = foreign.AvatarType == AvatarType.Group, + Discoverable = foreign.IsDiscoverable, + CreatedAt = MastodonJson.Day(foreign.CreatedAt), + Note = foreign.Biography ?? string.Empty, + Url = foreign.Url ?? foreign.ActorURI, + Uri = foreign.ActorURI, + Avatar = foreign.PictureURL ?? MissingAvatar, + AvatarStatic = foreign.PictureURL ?? MissingAvatar, + Header = foreign.ThumbnailURL ?? MissingHeader, + HeaderStatic = foreign.ThumbnailURL ?? MissingHeader + }; + + public async Task Account(string id, CancellationToken token) => + (await Accounts(new[] { id }, token)).GetValueOrDefault(id); + + public async Task> Accounts(IEnumerable ids, CancellationToken token) + { + var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); + var accounts = new Dictionary(); + if (wanted.Count == 0) + return accounts; + + foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) + accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); + foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token)) + accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token); + foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token)) + accounts[foreign.ID] = Foreign(foreign); + return accounts; + } + + public async Task Status(PostEntity post, string viewerId, CancellationToken token) => + (await Statuses(new[] { post }, viewerId, token)).FirstOrDefault(); + + public async Task> Statuses(IReadOnlyCollection posts, string viewerId, CancellationToken token) + { + var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); + var originals = originalIds.Count == 0 + ? new Dictionary() + : (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); + var all = posts.Concat(originals.Values).ToList(); + + var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList(); + var mentionAccounts = mentionUris.Count == 0 + ? new Dictionary() + : (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI); + var accounts = await Accounts(all.Select(AuthorOf), token); + + var ids = all.Select(p => p.ID).ToList(); + var favourited = viewerId == default + ? new HashSet() + : (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet(); + var reblogged = viewerId == default + ? new HashSet() + : (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) + .Select(p => p.ReblogOfPostId).ToHashSet(); + + Status Map(PostEntity post) + { + if (!accounts.TryGetValue(AuthorOf(post), out var account)) + return default; + var status = new Status + { + Id = post.ID, + Uri = post.ObjectURI, + Url = post.Url ?? post.ObjectURI, + Account = account, + InReplyToId = post.AnsweringToPostId, + InReplyToAccountId = post.InReplyToAccountId, + Content = Content(post), + CreatedAt = MastodonJson.Time(post.CreationDate), + EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default, + RepliesCount = post.RepliesCount, + ReblogsCount = post.ReblogsCount, + FavouritesCount = post.FavouritesCount, + Favourited = favourited.Contains(post.ID), + Reblogged = reblogged.Contains(post.ID), + Sensitive = post.HasContentWarning, + SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), + Visibility = Visibility(post.Visibility), + Language = post.Language, + MediaAttachments = post.Media.Select(Media).ToList(), + Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), + Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList() + }; + return status; + } + + var mapped = new List(); + foreach (var post in posts) + { + var status = Map(post); + if (status == default) + continue; + if (post.ReblogOfPostId != default) + { + if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner) + continue; + status.Reblog = inner; + status.Content = string.Empty; + status.Reblogged = reblogged.Contains(original.ID); + } + mapped.Add(status); + } + return mapped; + } + + public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId; + + public static string Content(PostEntity post) + { + var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text); + return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy + ? content ?? string.Empty + : $"

{WebUtility.HtmlEncode(post.Title)}

{content}"; + } + + public static string Visibility(PostVisibility visibility) => visibility switch + { + PostVisibility.Unlisted => "unlisted", + PostVisibility.FollowersOnly or PostVisibility.Circle => "private", + PostVisibility.Direct => "direct", + _ => "public" + }; + + StatusMention Mention(PostMention mention, IReadOnlyDictionary foreign) + { + if (mention.IsLocal) + { + var handle = mention.Handle?.TrimStart('@').Split('@')[0]; + return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI }; + } + if (!foreign.TryGetValue(mention.ActorURI, out var account)) + return default; + return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI }; + } + + static MediaAttachment Media(PostMedia media) => new() + { + Id = media.Id.ToString("N"), + Type = media.ContentType switch + { + { } type when type.StartsWith("image/gif") => "gifv", + { } type when type.StartsWith("image/") => "image", + { } type when type.StartsWith("video/") => "video", + { } type when type.StartsWith("audio/") => "audio", + _ => "unknown" + }, + Url = media.URL ?? media.RemoteURL, + PreviewUrl = media.URL ?? media.RemoteURL, + RemoteUrl = media.RemoteURL, + Description = media.Description, + Blurhash = media.Blurhash, + Meta = media.Width.HasValue && media.Height.HasValue + ? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } } + : default + }; + } +} diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index f6b9606..83208e6 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -190,7 +190,7 @@ namespace PrivaPub.Federation.Rendering return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}"); } - static string FieldValue(string value) + public static string FieldValue(string value) { var encoded = WebUtility.HtmlEncode(value ?? string.Empty); return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http" diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 49524c3..7aaff47 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.ResponseCompression; +using OpenIddict.Validation.AspNetCore; + using PrivaPub.ClientModels; using PrivaPub.Extensions; using PrivaPub.Models; @@ -27,6 +29,8 @@ namespace PrivaPub.Middleware { public static class PrivaPubConfigurations { + const string SchemeSelector = "PrivaPub"; + public static IServiceCollection PrivaPubAppSettingsConfiguration(this IServiceCollection service, IConfiguration configuration) { return service @@ -89,10 +93,14 @@ namespace PrivaPub.Middleware }) .AddAuthentication(options => { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = SchemeSelector; + options.DefaultChallengeScheme = SchemeSelector; + options.DefaultScheme = SchemeSelector; }) + .AddPolicyScheme(SchemeSelector, SchemeSelector, options => options.ForwardDefaultSelector = context => + context.Request.Path.StartsWithSegments("/api") + ? OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme + : JwtBearerDefaults.AuthenticationScheme) .AddPrivaPubAuth(configuration) .Services .AddSingleton() @@ -179,7 +187,7 @@ namespace PrivaPub.Middleware configure.AllowAnyMethod() .AllowAnyHeader() .AllowAnyOrigin() - .AllowAnyMethod() + .WithExposedHeaders("Link", "X-RateLimit-Remaining", "X-RateLimit-Reset") .DisallowCredentials(); }); }); diff --git a/PrivaPub/PrivaPub.csproj b/PrivaPub/PrivaPub.csproj index b4451a7..bc3bfe8 100644 --- a/PrivaPub/PrivaPub.csproj +++ b/PrivaPub/PrivaPub.csproj @@ -12,6 +12,8 @@ + + diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 3a4a35d..1f98cc8 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -11,6 +11,7 @@ using Serilog; using PrivaPub.Data; using PrivaPub.Extensions; +using PrivaPub.Api.Mastodon.Auth; using PrivaPub.Infrastructure; using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Data; @@ -54,6 +55,8 @@ try .PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubCORSConfiguration() .PrivaPubRateLimiting() + .PrivaPubOAuth(builder.Environment) + .AddScoped() .PrivaPubMiddlewareConfiguration(); } catch (Exception ex) diff --git a/PrivaPub/Services/JwtEvents.cs b/PrivaPub/Services/JwtEvents.cs index cc271ae..67d40ec 100644 --- a/PrivaPub/Services/JwtEvents.cs +++ b/PrivaPub/Services/JwtEvents.cs @@ -19,7 +19,7 @@ namespace PrivaPub.Services try { var localizer = context.HttpContext.RequestServices.GetRequiredService>(); - var webResult = new WebResult().Invalidate(localizer["Unauthorized: {0}", context.Exception], StatusCodes.Status401Unauthorized); + var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized); context.Response.ContentType = contentType; await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); } @@ -35,7 +35,7 @@ namespace PrivaPub.Services try { var localizer = context.HttpContext.RequestServices.GetRequiredService>(); - var webResult = new WebResult().Invalidate(localizer["Forbidden: {0}", context.Result.None ? "N/A" : context.Result.Failure?.Message ?? "N/A"], StatusCodes.Status403Forbidden); + var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden); context.Response.ContentType = contentType; await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); } diff --git a/PrivaPub/Web/Pages/OAuth/Authorize.cshtml b/PrivaPub/Web/Pages/OAuth/Authorize.cshtml new file mode 100644 index 0000000..3c929c3 --- /dev/null +++ b/PrivaPub/Web/Pages/OAuth/Authorize.cshtml @@ -0,0 +1,29 @@ +@page "/oauth/authorize" +@model PrivaPub.Web.Pages.OAuth.AuthorizeModel +@{ + ViewData["Title"] = "Authorize"; +} +

Authorize @Model.ApplicationName

+

It asks for: @string.Join(", ", Model.RequestedScopes).

+@if (Model.Avatars.Count == 0) +{ +

This login has no persona yet. Create one first.

+} +else +{ +
+ @foreach (var parameter in Model.Parameters) + { + + } +
+ Act as + @foreach (var avatar in Model.Avatars) + { +

+ } +
+

Each persona is a separate account to the application. Choosing one does not reveal the others.

+

+
+} diff --git a/PrivaPub/Web/Pages/OAuth/Login.cshtml b/PrivaPub/Web/Pages/OAuth/Login.cshtml new file mode 100644 index 0000000..49cfb6f --- /dev/null +++ b/PrivaPub/Web/Pages/OAuth/Login.cshtml @@ -0,0 +1,18 @@ +@page "/oauth/login" +@model PrivaPub.Web.Pages.OAuth.LoginModel +@{ + ViewData["Title"] = "Sign in"; +} +

Sign in to PrivaPub

+

Sign in with your private login. You will then choose which persona the application acts as; the +application never sees the login.

+
+ +

+

+ @if (Model.Error != default) + { +

@Model.Error

+ } +

+
diff --git a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs new file mode 100644 index 0000000..10063be --- /dev/null +++ b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs @@ -0,0 +1,165 @@ +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; + } + } +} diff --git a/PrivaPub/wwwroot/media/missing-avatar.png b/PrivaPub/wwwroot/media/missing-avatar.png new file mode 100644 index 0000000..e0772cb Binary files /dev/null and b/PrivaPub/wwwroot/media/missing-avatar.png differ diff --git a/PrivaPub/wwwroot/media/missing-header.png b/PrivaPub/wwwroot/media/missing-header.png new file mode 100644 index 0000000..e0beba0 Binary files /dev/null and b/PrivaPub/wwwroot/media/missing-header.png differ