Add support for registration invites

This commit is contained in:
Laura Hausmann 2024-01-27 23:50:31 +01:00
parent 3c72d50459
commit 3a466b2e0c
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
12 changed files with 70 additions and 44 deletions

View file

@ -28,14 +28,17 @@ public class AuthController(DatabaseContext db, UserService userSvc) : Controlle
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TimelineResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
public async Task<IActionResult> Login([FromBody] AuthRequest request) {
var user = await db.Users.FirstOrDefaultAsync(p => p.Host == null &&
p.UsernameLower == request.Username.ToLowerInvariant());
if (user == null) return Unauthorized();
if (user == null)
return StatusCode(StatusCodes.Status403Forbidden);
var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
if (profile?.Password == null) return Unauthorized();
if (!AuthHelpers.ComparePassword(request.Password, profile.Password)) return Unauthorized();
if (profile?.Password == null)
return StatusCode(StatusCodes.Status403Forbidden);
if (!AuthHelpers.ComparePassword(request.Password, profile.Password))
return StatusCode(StatusCodes.Status403Forbidden);
var res = await db.AddAsync(new Session {
Id = IdHelpers.GenerateSlowflakeId(),
@ -65,11 +68,12 @@ public class AuthController(DatabaseContext db, UserService userSvc) : Controlle
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TimelineResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
public async Task<IActionResult> Register([FromBody] AuthRequest request) {
//TODO: captcha support
//TODO: invite support
await userSvc.CreateLocalUser(request.Username, request.Password);
await userSvc.CreateLocalUser(request.Username, request.Password, request.Invite);
return await Login(request);
}

View file

@ -1,21 +0,0 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Schemas;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController]
[Tags("Mastodon")]
[EnableRateLimiting("sliding")]
[Produces("application/json")]
[Route("/api/v1")]
public class MastodonAuthController() : Controller {
}

View file

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController]
[Tags("Mastodon")]
[EnableRateLimiting("sliding")]
[Produces("application/json")]
[Route("/api/v1")]
public class MastodonAuthController : Controller { }

View file

@ -3,6 +3,7 @@ using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Schemas;
public class AuthRequest {
[J("username")] public required string Username { get; set; }
[J("password")] public required string Password { get; set; }
[J("username")] public required string Username { get; set; }
[J("password")] public required string Password { get; set; }
[J("invite")] public string? Invite { get; set; }
}

View file

@ -36,8 +36,9 @@ public sealed class Config {
}
public sealed class SecuritySection {
public required bool AuthorizedFetch { get; init; } = true;
public required ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public required bool AuthorizedFetch { get; init; } = true;
public required ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public required Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
}
public sealed class DatabaseSection {

View file

@ -1,6 +1,6 @@
namespace Iceshrimp.Backend.Core.Configuration;
public class Constants {
public const string UserAgent = "Iceshrimp/2024.1-experimental (https://shrimp-next.fedi.solutions)";
public static readonly string[] SystemUsers = { "instance.actor", "relay.actor" };
public static class Constants {
public const string ActivityStreamsNs = "https://www.w3.org/ns/activitystreams";
public static readonly string[] SystemUsers = { "instance.actor", "relay.actor" };
}

View file

@ -0,0 +1,9 @@
namespace Iceshrimp.Backend.Core.Configuration;
public static class Enums {
public enum Registrations {
Closed = 0,
Invite = 1,
Open = 2
}
}

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Core.Configuration;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
@ -13,7 +14,7 @@ public class ASActivity : ASObject {
public ASObject? Object { get; set; }
public static class Types {
private const string Ns = "https://www.w3.org/ns/activitystreams";
private const string Ns = Constants.ActivityStreamsNs;
public const string Create = $"{Ns}#Create";
public const string Delete = $"{Ns}#Delete";

View file

@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
@ -134,7 +135,7 @@ public class ASActor : ASObject {
}
public static class Types {
private const string Ns = "https://www.w3.org/ns/activitystreams";
private const string Ns = Constants.ActivityStreamsNs;
public const string Application = $"{Ns}#Application";
public const string Group = $"{Ns}#Group";

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
@ -55,7 +56,7 @@ public class ASNote : ASObject {
}
public static class Types {
private const string Ns = "https://www.w3.org/ns/activitystreams";
private const string Ns = Constants.ActivityStreamsNs;
public const string Note = $"{Ns}#Note";
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Security.Cryptography;
using Iceshrimp.Backend.Core.Configuration;
@ -13,7 +14,9 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services;
public class UserService(
IOptions<Config.InstanceSection> config,
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.SecuritySection> security,
IOptions<Config.InstanceSection> instance,
ILogger<UserService> logger,
DatabaseContext db,
ActivityFetcherService fetchSvc) {
@ -28,8 +31,8 @@ public class UserService(
public async Task<User?> GetUserFromQuery(string query) {
if (query.StartsWith("http://") || query.StartsWith("https://"))
if (query.StartsWith($"https://{config.Value.WebDomain}/users/")) {
query = query[$"https://{config.Value.WebDomain}/users/".Length..];
if (query.StartsWith($"https://{instance.Value.WebDomain}/users/")) {
query = query[$"https://{instance.Value.WebDomain}/users/".Length..];
return await db.Users.FirstOrDefaultAsync(p => p.Id == query) ??
throw GracefulException.NotFound("User not found");
}
@ -38,7 +41,7 @@ public class UserService(
}
var tuple = AcctToTuple(query);
if (tuple.Host == config.Value.WebDomain || tuple.Host == config.Value.AccountDomain)
if (tuple.Host == instance.Value.WebDomain || tuple.Host == instance.Value.AccountDomain)
tuple.Host = null;
return await db.Users.FirstOrDefaultAsync(p => p.Username == tuple.Username && p.Host == tuple.Host);
}
@ -97,13 +100,21 @@ public class UserService(
return user;
}
public async Task<User> CreateLocalUser(string username, string password) {
public async Task<User> CreateLocalUser(string username, string password, string? invite) {
//TODO: invite system should allow multi-use invites & time limited invites
if (security.Value.Registrations == Enums.Registrations.Closed)
throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code");
if (security.Value.Registrations == Enums.Registrations.Invite &&
!await db.RegistrationTickets.AnyAsync(p => p.Code == invite))
throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
if (username.Contains('.'))
throw new GracefulException(HttpStatusCode.BadRequest, "Username must not contain the dot character");
if (Constants.SystemUsers.Contains(username.ToLowerInvariant()))
throw new GracefulException(HttpStatusCode.BadRequest, "Username must not be a system user");
if (await db.Users.AnyAsync(p => p.Host == null && p.UsernameLower == username.ToLowerInvariant()))
throw new GracefulException(HttpStatusCode.BadRequest, "User already exists");
if (await db.UsedUsernames.AnyAsync(p => p.Username.ToLower() == username.ToLowerInvariant()))
throw new GracefulException(HttpStatusCode.BadRequest, "Username was already used");
@ -132,6 +143,9 @@ public class UserService(
Username = username.ToLowerInvariant()
};
var ticket = await db.RegistrationTickets.FirstAsync(p => p.Code == invite);
db.Remove(ticket);
await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername);
await db.SaveChangesAsync();

View file

@ -7,10 +7,14 @@ AccountDomain = example.org
;; Whether to require incoming ActivityPub requests carry a valid HTTP or LD signature
AuthorizedFetch = true
;; The level of detail in API error responses.
;; The level of detail in API error responses
;; Options: [None, Basic, Full]
ExceptionVerbosity = Basic
;; Whether to allow instance registrations
;; Options: [Closed, Invite, Open]
Registrations = Closed
[Database]
Host = localhost
Database = iceshrimp