Add Mastodon OAuth implementation

This commit is contained in:
Laura Hausmann 2024-01-30 21:49:50 +01:00
parent 8b7c227619
commit f5c2ed46c8
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
11 changed files with 1921 additions and 57 deletions

View file

@ -6,6 +6,7 @@ using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -13,9 +14,8 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[Tags("Mastodon")]
[EnableRateLimiting("sliding")]
[Produces("application/json")]
[Route("/api/v1")]
public class MastodonAuthController(DatabaseContext db) : Controller {
[HttpGet("verify_credentials")]
[HttpGet("/api/v1/apps/verify_credentials")]
[AuthenticateOauth]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MastodonAuth.VerifyCredentialsResponse))]
@ -32,7 +32,7 @@ public class MastodonAuthController(DatabaseContext db) : Controller {
return Ok(res);
}
[HttpPost("apps")]
[HttpPost("/api/v1/apps")]
[EnableRateLimiting("strict")]
[ConsumesHybrid]
[Produces("application/json")]
@ -78,4 +78,53 @@ public class MastodonAuthController(DatabaseContext db) : Controller {
return Ok(res);
}
[HttpPost("/oauth/token")]
[ConsumesHybrid]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MastodonAuth.OauthTokenResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetOauthToken([FromHybrid] MastodonAuth.OauthTokenRequest request) {
//TODO: app-level access (grant_type = "client_credentials")
if (request.GrantType != "code")
throw GracefulException.BadRequest("Invalid grant_type");
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Code == request.Code &&
p.App.ClientId == request.ClientId &&
p.App.ClientSecret == request.ClientSecret);
if (token == null)
throw GracefulException
.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
if (token.Active)
throw GracefulException
.BadRequest("The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.");
if (MastodonOauthHelpers.ExpandScopes(request.Scopes)
.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any())
throw GracefulException.BadRequest("The requested scope is invalid, unknown, or malformed.");
token.Scopes = request.Scopes;
token.Active = true;
await db.SaveChangesAsync();
var res = new MastodonAuth.OauthTokenResponse {
CreatedAt = token.CreatedAt,
Scopes = token.Scopes,
AccessToken = token.Token
};
return Ok(res);
}
/*
[HttpPost("/oauth/revoke")]
[ConsumesHybrid]
[Produces("application/json")]
//[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MastodonAuth.RegisterAppResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> RegisterApp([FromHybrid] ) { }
*/
}

View file

@ -24,10 +24,14 @@ public abstract class MastodonAuth {
[B(Name = "scopes")]
[J("scopes")]
[JC(typeof(EnsureArrayConverter))]
public List<string> Scopes {
public List<string>? Scopes {
get => _scopes;
set => _scopes = value.Count == 1
set => _scopes = value == null
? ["read"]
: value.Count == 1
? value.Contains(" ")
? value[0].Split(' ').ToList()
: value[0].Split(',').ToList()
: value;
}
@ -59,4 +63,55 @@ public abstract class MastodonAuth {
[J("vapid_key")] public required string? VapidKey { get; set; }
}
public class OauthTokenRequest {
public List<string> Scopes = ["read"];
[B(Name = "scope")]
[J("scope")]
[JC(typeof(EnsureArrayConverter))]
public List<string>? ScopesInternal {
get => Scopes;
set => Scopes = value == null
? ["read"]
: value.Count == 1
? value.Contains(" ")
? value[0].Split(' ').ToList()
: value[0].Split(',').ToList()
: value;
}
[B(Name = "redirect_uri")]
[J("redirect_uri")]
[JR]
public string RedirectUri { get; set; } = null!;
[B(Name = "grant_type")]
[J("grant_type")]
[JR]
public string GrantType { get; set; } = null!;
[B(Name = "client_id")]
[J("client_id")]
[JR]
public string ClientId { get; set; } = null!;
[B(Name = "client_secret")]
[J("client_secret")]
[JR]
public string ClientSecret { get; set; } = null!;
[B(Name = "code")] [J("code")] public string? Code { get; set; } = null!;
}
public class OauthTokenResponse {
public required DateTime CreatedAt;
public required List<string> Scopes;
[J("access_token")] public required string AccessToken { get; set; }
[J("token_type")] public string TokenType => "Bearer";
[J("scope")] public string Scope => string.Join(' ', Scopes);
[J("created_at")] public long CreatedAtInternal => (long)(CreatedAt - DateTime.UnixEpoch).TotalSeconds;
}
}

View file

@ -32,6 +32,7 @@ public static class ServiceExtensions {
services.AddScoped<WebFingerService>();
services.AddScoped<AuthorizedFetchMiddleware>();
services.AddScoped<AuthenticationMiddleware>();
services.AddScoped<OauthAuthenticationMiddleware>();
// Singleton = instantiated once across application lifetime
services.AddSingleton<HttpClient>();
@ -41,6 +42,7 @@ public static class ServiceExtensions {
services.AddSingleton<ErrorHandlerMiddleware>();
services.AddSingleton<RequestBufferingMiddleware>();
services.AddSingleton<AuthorizationMiddleware>();
services.AddSingleton<OauthAuthorizationMiddleware>();
// Hosted services = long running background tasks
// Note: These need to be added as a singleton as well to ensure data consistency

View file

@ -13,6 +13,8 @@ public static class WebApplicationExtensions {
.UseMiddleware<RequestBufferingMiddleware>()
.UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>()
.UseMiddleware<OauthAuthenticationMiddleware>()
.UseMiddleware<OauthAuthorizationMiddleware>()
.UseMiddleware<AuthorizedFetchMiddleware>();
}

View file

@ -0,0 +1,45 @@
@page "/oauth/authorize"
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Microsoft.AspNetCore.WebUtilities
@model AuthorizeModel
<h3>Iceshrimp.NET OAuth</h3>
@if (Model.Token == null) {
<div>
The app <span class="app_name">@Model.App.Name</span> requests the following permissions:
<ul>
@foreach (var scope in Model.Scopes) {
<li>@scope</li>
}
</ul>
Log in below to confirm this:
<div class="form-wrapper">
<form method="post">
<input type="text" placeholder="Username" name="username"/>
<input type="password" placeholder="Password" name="password"/>
<button type="submit">Submit</button>
</form>
</div>
</div>
}
else if (Model.Token.RedirectUri == "urn:ietf:wg:oauth:2.0:oob") {
<div>
Your code is: <pre>@Model.Token.Code</pre>
</div>
}
else {
var uri = new Uri(Model.Token.RedirectUri);
var query = QueryHelpers.ParseQuery(uri.Query);
query.Add("code", Model.Token.Code);
if (Request.Query.ContainsKey("state"))
query.Add("state", Request.Query["state"]);
uri = new Uri(QueryHelpers.AddQueryString(Model.Token.RedirectUri, query));
Response.Redirect(uri.ToString());
<div>
Click <a href="@uri.ToString()">here</a> to be redirected back to your application
</div>
}

View file

@ -0,0 +1,71 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Pages.OAuth;
public class AuthorizeModel(DatabaseContext db) : PageModel {
[FromQuery(Name = "response_type")] public required string ResponseType { get; set; }
[FromQuery(Name = "client_id")] public required string ClientId { get; set; }
[FromQuery(Name = "redirect_uri")] public required string RedirectUri { get; set; }
[FromQuery(Name = "force_login")] public bool ForceLogin { get; set; } = false;
[FromQuery(Name = "lang")] public string? Language { get; set; }
[FromQuery(Name = "scope")]
public string Scope {
get => string.Join(' ', Scopes);
[MemberNotNull(nameof(Scopes))] set => Scopes = value.Split(' ').ToList();
}
public List<string> Scopes = [];
public OauthApp App = null!;
public OauthToken? Token = null;
public async Task OnGet() {
App = await db.OauthApps.FirstOrDefaultAsync(p => p.ClientId == ClientId)
?? throw GracefulException.BadRequest("Invalid client_id");
if (MastodonOauthHelpers.ExpandScopes(Scopes).Except(MastodonOauthHelpers.ExpandScopes(App.Scopes)).Any())
throw GracefulException.BadRequest("Cannot request more scopes than app");
if (ResponseType != "code")
throw GracefulException.BadRequest("Invalid response_type");
if (!App.RedirectUris.Contains(RedirectUri))
throw GracefulException.BadRequest("Cannot request redirect_uri not sent during app registration");
}
public async Task OnPost([FromForm] string username, [FromForm] string password) {
// Validate query parameters first
await OnGet();
var user = await db.Users.FirstOrDefaultAsync(p => p.Host == null &&
p.UsernameLower == username.ToLowerInvariant());
if (user == null)
throw GracefulException.Forbidden("Invalid username or password");
var userProfile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
if (userProfile?.Password == null)
throw GracefulException.Forbidden("Invalid username or password");
if (AuthHelpers.ComparePassword(password, userProfile.Password) == false)
throw GracefulException.Forbidden("Invalid username or password");
var token = new OauthToken {
Id = IdHelpers.GenerateSlowflakeId(),
Active = false,
Code = CryptographyHelpers.GenerateRandomString(32),
Token = CryptographyHelpers.GenerateRandomString(32),
App = App,
User = user,
CreatedAt = DateTime.UtcNow,
Scopes = Scopes,
RedirectUri = RedirectUri
};
await db.AddAsync(token);
await db.SaveChangesAsync();
Token = token;
}
}

View file

@ -0,0 +1,7 @@
.app_name {
color: #9a92ff;
}
.form-wrapper {
padding-top: 5px;
}

View file

@ -2,6 +2,10 @@
@addTagHelper *, Vite.AspNetCore
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -3,9 +3,11 @@
<head>
<meta charset="utf-8"/>
<title>Iceshrimp</title>
@* ReSharper disable once Html.PathError *@
<link rel="stylesheet" href="~/Iceshrimp.Backend.styles.css"/>
<link rel="stylesheet" href="~/css/default.css"/>
</head>
<body>
<div id="app"></div>
@RenderBody()
</body>
</html>

View file

@ -1,49 +1 @@
/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}


File diff suppressed because it is too large Load diff