Add Mastodon OAuth implementation
This commit is contained in:
parent
8b7c227619
commit
f5c2ed46c8
11 changed files with 1921 additions and 57 deletions
|
@ -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] ) { }
|
||||
|
||||
*/
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -13,6 +13,8 @@ public static class WebApplicationExtensions {
|
|||
.UseMiddleware<RequestBufferingMiddleware>()
|
||||
.UseMiddleware<AuthenticationMiddleware>()
|
||||
.UseMiddleware<AuthorizationMiddleware>()
|
||||
.UseMiddleware<OauthAuthenticationMiddleware>()
|
||||
.UseMiddleware<OauthAuthorizationMiddleware>()
|
||||
.UseMiddleware<AuthorizedFetchMiddleware>();
|
||||
}
|
||||
|
||||
|
|
45
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml
Normal file
45
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml
Normal 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>
|
||||
}
|
71
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml.cs
Normal file
71
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml.cs
Normal 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;
|
||||
}
|
||||
}
|
7
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml.css
Normal file
7
Iceshrimp.Backend/Pages/OAuth/Authorize.cshtml.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.app_name {
|
||||
color: #9a92ff;
|
||||
}
|
||||
|
||||
.form-wrapper {
|
||||
padding-top: 5px;
|
||||
}
|
|
@ -2,6 +2,10 @@
|
|||
@addTagHelper *, Vite.AspNetCore
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
|
1675
Iceshrimp.Backend/wwwroot/css/default.css
Normal file
1675
Iceshrimp.Backend/wwwroot/css/default.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue