diff options
Diffstat (limited to 'back-end/src/Endpoints/BmAccountEndpoint.cs')
-rw-r--r-- | back-end/src/Endpoints/BmAccountEndpoint.cs | 492 |
1 files changed, 492 insertions, 0 deletions
diff --git a/back-end/src/Endpoints/BmAccountEndpoint.cs b/back-end/src/Endpoints/BmAccountEndpoint.cs new file mode 100644 index 0000000..9b57d39 --- /dev/null +++ b/back-end/src/Endpoints/BmAccountEndpoint.cs @@ -0,0 +1,492 @@ +// 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 <https://www.gnu.org/licenses/>. + +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<NewUserRequest> NewRequestVal = NewUserRequest.GetValidator(); + private static readonly IValidator<RegSubmitRequest> RegSubmitVal = RegSubmitRequest.GetValidator(); + private static readonly IValidator<RegSubmitRequest> 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<UserManager>(); + //Bookmarks = plugin.GetOrCreateSingleton<BookmarkStore>(); + + /* + * 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(); + + 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<VfReturnType> 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<NewUserRequest>(); + 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<VfReturnType> 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<RegSubmitRequest>(); + + 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<JwtPayload>()!; + + //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<VfReturnType> DeleteAsync(HttpEntity entity) + { + return base.DeleteAsync(entity); + } + + private sealed class JwtAuthManager() : IIntervalScheduleable + { + /* + * Random signing keys are rotated on the configured expiration + * interval. + */ + + private byte[] secretKey = RandomHash.GetRandomBytes(64); + + Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) + { + secretKey = RandomHash.GetRandomBytes(64); + return Task.CompletedTask; + } + + public void SignJwt(JsonWebToken jwt) + { + if (ManagedHash.IsAlgSupported(HashAlg.BlAKE2B)) + { + jwt.Sign(secretKey, HashAlg.BlAKE2B); + } + else if (ManagedHash.IsAlgSupported(HashAlg.SHA3_256)) + { + jwt.Sign(secretKey, HashAlg.SHA3_256); + } + else + { + //fallback to sha256 + jwt.Sign(secretKey, HashAlg.SHA256); + } + } + + public bool VerifyJwt(JsonWebToken jwt) + { + if (ManagedHash.IsAlgSupported(HashAlg.BlAKE2B)) + { + return jwt.Verify(secretKey, HashAlg.BlAKE2B); + } + else if (ManagedHash.IsAlgSupported(HashAlg.SHA3_256)) + { + return jwt.Verify(secretKey, HashAlg.SHA3_256); + } + else + { + //fallback to sha256 + return jwt.Verify(secretKey, 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<NewUserRequest> GetValidator() + { + InlineValidator<NewUserRequest> 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<RegSubmitRequest> GetValidator() + { + InlineValidator<RegSubmitRequest> 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<RegSubmitRequest> GetAdminValidator() + { + InlineValidator<RegSubmitRequest> 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; + } + } + } +} |