// Copyright (C) 2024 Vaughn Nugent // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using System; using System.Net; using System.Threading; using System.Threading.Tasks; using System.Text.Json.Serialization; using FluentValidation; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Plugins; using VNLib.Plugins.Essentials; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Essentials.Endpoints; using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Validation; using VNLib.Plugins.Extensions.Loading.Users; using VNLib.Plugins.Extensions.Loading.Events; using SimpleBookmark.Model; namespace SimpleBookmark.Endpoints { [ConfigurationName("registration")] internal sealed class BmAccountEndpoint : UnprotectedWebEndpoint { private static readonly IValidator NewRequestVal = NewUserRequest.GetValidator(); private static readonly IValidator RegSubmitVal = RegSubmitRequest.GetValidator(); private static readonly IValidator AdminRegVal = RegSubmitRequest.GetAdminValidator(); private readonly IUserManager Users; private readonly TimeSpan Expiration; private readonly JwtAuthManager AuthMan; //private readonly BookmarkStore Bookmarks; private readonly bool SetupMode; private readonly bool Enabled; public BmAccountEndpoint(PluginBase plugin, IConfigScope config) { string path = config.GetRequiredProperty("path", p => p.GetString()!); InitPathAndLog(path, plugin.Log); //get setup mode and enabled startup arguments SetupMode = plugin.HostArgs.HasArgument("--setup"); Enabled = !plugin.HostArgs.HasArgument("--disable-registation"); Expiration = config.GetRequiredProperty("token_lifetime_mins", p => p.GetTimeSpan(TimeParseType.Minutes)); Users = plugin.GetOrCreateSingleton(); //Bookmarks = plugin.GetOrCreateSingleton(); /* * JWT manager allows regenerating the signing key on a set interval. * * This means that if keys are generated on the edge of an interval, * it will expire at the next interval which could be much shorter * than the set interval. This is a security feature to prevent * long term exposure of a signing key. * */ AuthMan = new JwtAuthManager(64); if(config.TryGetProperty("key_regen_interval_mins", p => p.GetTimeSpan(TimeParseType.Minutes), out TimeSpan regen)) { plugin.ScheduleInterval(AuthMan, regen, false); } } //Essentially a whoami endpoint for current user protected override VfReturnType Get(HttpEntity entity) { WebMessage msg = new() { Success = true }; //Only authorized users can check their status if (Enabled && entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) { //Pass user status when logged in msg.Result = new StatusResponse { SetupMode = SetupMode, Enabled = Enabled, CanInvite = entity.Session.CanAddUser(), ExpirationTime = (int)Expiration.TotalSeconds }; } else { msg.Result = new StatusResponse { SetupMode = SetupMode, Enabled = Enabled }; } return VirtualOk(entity, msg); } /* * PUT will generate a new user request if the current has an admin * role. This will return a jwt token that can be used to register a new * user account. The token will expire after a set time. */ protected override async ValueTask PutAsync(HttpEntity entity) { ValErrWebMessage webm = new(); if (webm.Assert(Enabled, "User registation was disabled via commandline")) { return VirtualClose(entity, webm, HttpStatusCode.Forbidden); } //Only authorized users can generate new requests if(!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) { webm.Result = "You do not have permissions to create new users"; return VirtualClose(entity, webm, HttpStatusCode.Unauthorized); } //Make sure user is an admin if (webm.Assert(entity.Session.CanAddUser(), "You do not have permissions to create new users")) { return VirtualClose(entity, webm, HttpStatusCode.Forbidden); } //Try to get the new user request NewUserRequest? request = entity.GetJsonFromFile(); if (webm.Assert(request != null, "No request body provided")) { return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } if(!NewRequestVal.Validate(request, webm)) { return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } //Make sure the user does not already exist using (IUser? user = await Users.GetUserFromUsernameAsync(request.Username!, entity.EventCancellation)) { if (webm.Assert(user == null, "User already exists")) { return VirtualClose(entity, webm, HttpStatusCode.Conflict); } } //Start with min user privilages ulong privileges = RoleHelpers.MinUserRole; //Optionally allow the user to add new userse if(request.CanAddUsers) { privileges = RoleHelpers.WithAddUserRole(privileges); } //Init new request using JsonWebToken jwt = new(); //issue new payload for registration jwt.WritePayload(new JwtPayload { Expiration = entity.RequestedTimeUtc.Add(Expiration).ToUnixTimeSeconds(), Subject = request.Username!, PrivLevel = privileges, Nonce = RandomHash.GetRandomHex(16) }); AuthMan.SignJwt(jwt); webm.Result = jwt.Compile(); webm.Success = true; return VirtualClose(entity, webm, HttpStatusCode.OK); } protected override async ValueTask PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); if (webm.Assert(Enabled, "User registation was disabled via commandline.")) { return VirtualClose(entity, webm, HttpStatusCode.Forbidden); } using RegSubmitRequest? req = await entity.GetJsonFromFileAsync(); if (webm.Assert(req != null, "No request body provided.")) { return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //Users can specify an admin username when setup mode is enabled if (!string.IsNullOrWhiteSpace(req.AdminUsername)) { if(webm.Assert(SetupMode, "Admin registation is not enabled.")) { return VirtualClose(entity, webm, HttpStatusCode.Forbidden); } //Validate against admin reg if(!AdminRegVal.Validate(req, webm)) { return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } try { //Default to min user privilages + add user privilages, technically the same as admin here ulong adminPriv = RoleHelpers.WithAddUserRole(RoleHelpers.MinUserRole); await CreateUserAsync(req.AdminUsername, adminPriv, req.Password, entity.EventCancellation); webm.Result = "Successfully created your new admin account."; webm.Success = true; return VirtualClose(entity, webm, HttpStatusCode.Created); } catch (UserExistsException) { webm.Result = "User account already exists"; return VirtualClose(entity, webm, HttpStatusCode.Conflict); } } //Normal link registration if(!RegSubmitVal.Validate(req, webm)) { return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } try { //Try to recover the initial jwt from the request using JsonWebToken jwt = JsonWebToken.Parse(req.Token!); if (webm.Assert(AuthMan.VerifyJwt(jwt), "Registation failed, your link is invalid.")) { return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } JwtPayload p = jwt.GetPayload()!; //Make sure the token is not expired if (webm.Assert(p.Expiration > entity.RequestedTimeUtc.ToUnixTimeSeconds(), "Registation failed, your link has expired")) { return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //Check that the user is not already registered using (IUser? user = await Users.GetUserFromUsernameAsync(p.Subject, entity.EventCancellation)) { if (webm.Assert(user == null, "Your account already exists")) { /* * It should be fine to tell the user that the account already exists * because login tokens are "secret" and the user would have to know * the token to use it. */ return VirtualClose(entity, webm, HttpStatusCode.Conflict); } } //Create the new user await CreateUserAsync(p.Subject, p.PrivLevel, req.Password, entity.EventCancellation); webm.Result = "Successfully created you new account!"; webm.Success = true; return VirtualClose(entity, webm, HttpStatusCode.Created); } catch (FormatException) { webm.Result = "Registation failed, your link is invalid."; return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } } private async Task CreateUserAsync(string userName, ulong privLevel, string? password, CancellationToken cancellation) { //Create the new user UserCreationRequest newUser = new() { EmailAddress = userName, InitialStatus = UserStatus.Active, Privileges = privLevel, Password = PrivateString.ToPrivateString(password, false), }; using IUser user = await Users.CreateUserAsync(newUser, null, cancellation); //Assign a local account status and email address user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN); user.EmailAddress = userName; await user.ReleaseAsync(cancellation); } /* * TODO * USERS DELETE OWN ACCOUNTS HERE * * Users may delete their own accounts if they are logged in. * This function should delete all bookmarks, and their own account * from the table. This requires password elevation aswell. */ protected override ValueTask DeleteAsync(HttpEntity entity) { return base.DeleteAsync(entity); } private sealed class JwtAuthManager(int keySize) : IIntervalScheduleable { /* * Random signing keys are rotated on the configured expiration * interval. */ private byte[] secretKey = RandomHash.GetRandomBytes(keySize); Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) { secretKey = RandomHash.GetRandomBytes(keySize); return Task.CompletedTask; } public void SignJwt(JsonWebToken jwt) => jwt.Sign(secretKey, GetHashAlg()); public bool VerifyJwt(JsonWebToken jwt) => jwt.Verify(secretKey, GetHashAlg()); private static HashAlg GetHashAlg() { if (ManagedHash.IsAlgSupported(HashAlg.BlAKE2B)) { return HashAlg.BlAKE2B; } else if (ManagedHash.IsAlgSupported(HashAlg.SHA3_256)) { return HashAlg.SHA3_256; } else { //fallback to sha256 return HashAlg.SHA256; } } } private sealed class JwtPayload { [JsonPropertyName("sub")] public string Subject { get; set; } = string.Empty; [JsonPropertyName("level")] public ulong PrivLevel { get; set; } [JsonPropertyName("exp")] public long Expiration { get; set; } [JsonPropertyName("n")] public string Nonce { get; set; } = string.Empty; } private sealed class NewUserRequest { [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; [JsonPropertyName("can_add_users")] public bool CanAddUsers { get; set; } public static IValidator GetValidator() { InlineValidator val = new(); val.RuleFor(p => p.Username) .NotNull() .NotEmpty() .EmailAddress() .Length(1, 200); return val; } } private sealed class StatusResponse { [JsonPropertyName("setup_mode")] public bool SetupMode { get; set; } [JsonPropertyName("enabled")] public bool Enabled { get; set; } [JsonPropertyName("can_invite")] public bool? CanInvite { get; set; } [JsonPropertyName("link_expiration")] public int? ExpirationTime { get; set; } } private sealed class RegSubmitRequest() : PrivateStringManager(1) { [JsonPropertyName("token")] public string? Token { get; set; } [JsonPropertyName("admin_username")] public string? AdminUsername { get; set; } [JsonPropertyName("password")] public string? Password { get => base[0]; set => base[0] = value; } public static IValidator GetValidator() { InlineValidator val = new(); val.RuleFor(p => p.Token) .NotNull() .NotEmpty() .Length(1, 500); val.RuleFor(p => p.Password) .NotEmpty() .Length(min: 8, max: 100) .Password() .WithMessage(errorMessage: "Password does not meet minium requirements"); return val; } public static IValidator GetAdminValidator() { InlineValidator val = new(); val.RuleFor(p => p.Password) .NotEmpty() .Length(min: 8, max: 100) .Password() .WithMessage(errorMessage: "Password does not meet minium requirements"); val.RuleFor(p => p.AdminUsername) .NotNull() .NotEmpty() .EmailAddress() .Length(1, 200); return val; } } } }