aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/README.md17
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/build.readme.md0
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/src/Model/UserEntry.cs92
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/src/Model/UsersContext.cs80
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/src/UserData.cs259
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/src/UserManager.cs417
-rw-r--r--lib/VNLib.Plugins.Essentials.Users/src/VNLib.Plugins.Essentials.Users.csproj65
-rw-r--r--plugins.essentials.build.sln9
8 files changed, 939 insertions, 0 deletions
diff --git a/lib/VNLib.Plugins.Essentials.Users/README.md b/lib/VNLib.Plugins.Essentials.Users/README.md
new file mode 100644
index 0000000..e7a9073
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/README.md
@@ -0,0 +1,17 @@
+# VNLib.Plugins.Essentials.Users
+*Common user/account library that contains data structures with an SQL backing store. This library may be consumed directly for accessing standard user related data structures.*
+
+**This library contains 3rd-party dependencies**
+
+## Builds
+Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below).
+
+## Docs and Guides
+Documentation, specifications, and setup guides are available on my website.
+
+[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_vnlib.plugins.essentials.users)
+[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials)
+[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules)
+
+## License
+Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file
diff --git a/lib/VNLib.Plugins.Essentials.Users/build.readme.md b/lib/VNLib.Plugins.Essentials.Users/build.readme.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/build.readme.md
diff --git a/lib/VNLib.Plugins.Essentials.Users/src/Model/UserEntry.cs b/lib/VNLib.Plugins.Essentials.Users/src/Model/UserEntry.cs
new file mode 100644
index 0000000..bc14076
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/src/Model/UserEntry.cs
@@ -0,0 +1,92 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Users
+* File: UserEntry.cs
+*
+* UserEntry.cs is part of VNLib.Plugins.Essentials.Users which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Users is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published
+* by the Free Software Foundation, either version 2 of the License,
+* or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Users 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
+* General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with VNLib.Plugins.Essentials.Users. If not, see http://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Data.Abstractions;
+
+namespace VNLib.Plugins.Essentials.Users.Model
+{
+
+ /// <summary>
+ /// An efcore model of the lowest level of the user's entry
+ /// in the table
+ /// </summary>
+ [Index(nameof(UserId), IsUnique = true)]
+ public class UserEntry : DbModelBase, IUserEntity
+ {
+ /// <summary>
+ /// The Unique ID of the user
+ /// </summary>
+ [Key]
+ [MaxLength(64)]
+#pragma warning disable CS8764 // Nullability of return type doesn't match overridden member (possibly because of nullability attributes).
+ public override string? Id { get; set; }
+#pragma warning restore CS8764 // Nullability of return type doesn't match overridden member (possibly because of nullability attributes).
+
+ /// <summary>
+ /// The secondary ID of the user, usually an EmailAddress
+ /// </summary>
+ [MaxLength(64)]
+ public string? UserId { get; set; }
+
+ ///<inheritdoc/>
+ public override DateTime Created { get; set; }
+
+ ///<inheritdoc/>
+ public override DateTime LastModified { get; set; }
+
+ /// <summary>
+ /// The user's privilage flags
+ /// </summary>
+ public long PrivilegeLevel { get; set; }
+
+ /// <summary>
+ /// The json-encoded raw user-data
+ /// </summary>
+ public byte[]? UserData { get; set; }
+
+ /// <summary>
+ /// The optional unguarded password hash of the user entry
+ /// </summary>
+ [MaxLength(1000)]
+ public string? PassHash { get; set; }
+
+ /// <summary>
+ /// A referrence to the <see cref="UserId"/>
+ /// parameter
+ /// </summary>
+ [NotMapped]
+ public string? EmailAddress
+ {
+ get => UserId;
+ set => UserId = value;
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/VNLib.Plugins.Essentials.Users/src/Model/UsersContext.cs b/lib/VNLib.Plugins.Essentials.Users/src/Model/UsersContext.cs
new file mode 100644
index 0000000..09f992e
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/src/Model/UsersContext.cs
@@ -0,0 +1,80 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Users
+* File: UsersContext.cs
+*
+* UsersContext.cs is part of VNLib.Plugins.Essentials.Users which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Users is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published
+* by the Free Software Foundation, either version 2 of the License,
+* or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Users 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
+* General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with VNLib.Plugins.Essentials.Users. If not, see http://www.gnu.org/licenses/.
+*/
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+
+namespace VNLib.Plugins.Essentials.Users.Model
+{
+ /// <summary>
+ /// The Efcore transactional database context
+ /// </summary>
+ public class UsersContext : DBContextBase, IDbTableDefinition
+ {
+ /// <summary>
+ /// The Users table
+ /// </summary>
+ public DbSet<UserEntry> Users { get; set; }
+
+#nullable disable
+
+ public UsersContext()
+ { }
+
+ public UsersContext(DbContextOptions options):base(options)
+ { }
+
+#nullable enable
+
+
+ ///<inheritdoc/>
+ public void OnDatabaseCreating(IDbContextBuilder builder, object? userState)
+ {
+ PluginBase plugin = (userState as PluginBase)!;
+
+ //Try to get the configuration for the users implementation
+ IConfigScope? userConfig = plugin.TryGetConfig("users");
+
+ //Maxium char size in most dbs
+ int userMaxLen = userConfig?.GetValueOrDefault<int>("max_data_size", 8000) ?? 8000;
+
+ //Define user-table from the users dbset field
+ builder.DefineTable<UserEntry>(nameof(Users), table =>
+ {
+ table.WithColumn(p => p.Id).AllowNull(false);
+ table.WithColumn(p => p.UserId).AllowNull(false);
+ table.WithColumn(p => p.LastModified);
+ table.WithColumn(p => p.Created);
+ table.WithColumn(p => p.PassHash);
+ table.WithColumn(p => p.PrivilegeLevel).AllowNull(false).WithDefault(AccountUtil.MINIMUM_LEVEL);
+ table.WithColumn(p => p.UserData).MaxLength(userMaxLen);
+ table.WithColumn(p => p.Version);
+ });
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/VNLib.Plugins.Essentials.Users/src/UserData.cs b/lib/VNLib.Plugins.Essentials.Users/src/UserData.cs
new file mode 100644
index 0000000..204a5c7
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/src/UserData.cs
@@ -0,0 +1,259 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Users
+* File: UserData.cs
+*
+* UserData.cs is part of VNLib.Plugins.Essentials.Users which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Users is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published
+* by the Free Software Foundation, either version 2 of the License,
+* or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Users 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
+* General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with VNLib.Plugins.Essentials.Users. If not, see http://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+using VNLib.Utils.Async;
+using VNLib.Plugins.Essentials.Users.Model;
+using static VNLib.Plugins.Essentials.Statics;
+
+namespace VNLib.Plugins.Essentials.Users
+{
+
+ /// <summary>
+ /// Represents a user and its entry in the primary user table
+ /// </summary>
+ public sealed class UserData : AsyncUpdatableResource, IUser
+ {
+ private sealed class UserDataObj
+ {
+ [JsonPropertyName("la")]
+ public long? LastActive { get; set; }
+
+ [JsonPropertyName("st")]
+ public UserStatus? Status { get; set; }
+
+ [JsonPropertyName("lo")]
+ public bool? LocalOnly { get; set; }
+
+ [JsonPropertyName("us")]
+ public Dictionary<string, string>? UserStorage { get; set; }
+ }
+
+ private readonly Lazy<UserDataObj> Properties;
+ internal readonly UserEntry Entry;
+
+ ///<inheritdoc/>
+ protected override IAsyncResourceStateHandler AsyncHandler { get; }
+
+ private bool Disposed;
+
+ internal UserData(IAsyncResourceStateHandler handler, UserEntry entry)
+ {
+ //Init the callbacks in async mode
+ Entry = entry;
+ AsyncHandler = handler;
+
+ //Undef the password hash in the entry
+ entry.PassHash = null;
+
+ //Lazy properties
+ Properties = new(LoadData, false);
+ }
+
+ private UserDataObj LoadData()
+ {
+ UserDataObj? props = null;
+ try
+ {
+ //Recover properties from stream
+ props = JsonSerializer.Deserialize<UserDataObj>(Entry.UserData, SR_OPTIONS) ?? new UserDataObj();
+ }
+ //Catch json exception for invalid data, propagate other exceptions
+ catch (JsonException)
+ {
+ //If an exception was thrown reading back the data object, set modified flag to overwrite on release
+ Modified = true;
+ }
+ //If props is null (or an exception is thrown,
+ return props ?? new();
+ }
+
+ ///<inheritdoc/>
+ public string UserID => Entry.Id!;
+
+ ///<inheritdoc/>
+ public string EmailAddress
+ {
+ get => Entry.EmailAddress!;
+ set
+ {
+ Check();
+ ArgumentException.ThrowIfNullOrEmpty(value, nameof(EmailAddress));
+
+ //Set modified flag if changed
+ Modified |= Entry.EmailAddress!.Equals(value, StringComparison.OrdinalIgnoreCase);
+ Entry.EmailAddress = value;
+ }
+ }
+
+ ///<inheritdoc/>
+ public ulong Privileges
+ {
+ get => (ulong)Entry.PrivilegeLevel;
+ set
+ {
+ Check();
+ //Set modified flag if changed
+ Modified |= (ulong)Entry.PrivilegeLevel != value;
+ Entry.PrivilegeLevel = unchecked((long)value);
+ }
+ }
+
+ ///<inheritdoc/>
+ public DateTimeOffset Created => Entry.Created;
+
+ ///<inheritdoc/>
+ public DateTimeOffset LastActive
+ {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(Properties.Value.LastActive ?? 0);
+ set
+ {
+ long unixMs = value.ToUnixTimeMilliseconds();
+ Modified |= Properties.Value.LastActive != unixMs;
+ Properties.Value.LastActive = unixMs;
+ }
+ }
+
+ ///<inheritdoc/>
+ public UserStatus Status
+ {
+ get => (Properties.Value.Status ?? UserStatus.Unverified);
+ set
+ {
+ Modified |= Properties.Value.Status != value;
+ Properties.Value.Status = value;
+ }
+ }
+
+ ///<inheritdoc/>
+ public bool LocalOnly
+ {
+ get => Properties.Value.LocalOnly ?? false;
+ set
+ {
+ Modified |= Properties.Value.LocalOnly != value;
+ Properties.Value.LocalOnly = value ? true : null;
+ }
+ }
+
+
+ /// <summary>
+ /// Users datastore of key-value string pairs
+ /// </summary>
+ /// <param name="key">Key for item in store</param>
+ /// <returns>The value string if found, string.Empty otherwise</returns>
+ public string this[string key]
+ {
+ get
+ {
+ Check();
+ string? val = null;
+ Properties.Value.UserStorage?.TryGetValue(key, out val);
+ return val ?? "";
+ }
+ set
+ {
+ Check();
+ //If the value is null, see if the the properties are null
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ //If properties are null exit
+ if (Properties.Value.UserStorage != null)
+ {
+ //If the value is null and properies exist, remove the entry
+ Properties.Value.UserStorage.Remove(key);
+ Modified = true;
+ }
+ }
+ else
+ {
+ Properties.Value.UserStorage ??= new();
+ //Set the value
+ Properties.Value.UserStorage[key] = value;
+ //Set modified flag
+ Modified = true;
+ }
+ }
+ }
+
+#nullable disable
+
+ ///<inheritdoc/>
+ public T GetObject<T>(string key)
+ {
+ Check();
+
+ //If user storage has been definied, then try to get the value
+ return Properties.Value.UserStorage?.TryGetValue(key, out string prop) == true
+ ? JsonSerializer.Deserialize<T>(prop, SR_OPTIONS)
+ : default;
+ }
+
+ ///<inheritdoc/>
+ public void SetObject<T>(string key, T obj)
+ {
+ Check();
+
+ this[key] = obj == null ? null : JsonSerializer.Serialize(obj, SR_OPTIONS);
+ }
+
+#nullable enable
+
+ ///<inheritdoc/>
+ public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
+ {
+ Check();
+
+ return Properties.Value.UserStorage != null
+ ? Properties.Value.UserStorage.GetEnumerator()
+ : (IEnumerator<KeyValuePair<string, string>>)new Dictionary<string, string>.Enumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ ///<inheritdoc/>
+ public void Dispose()
+ {
+ if (!Disposed)
+ {
+ Properties.Value.UserStorage?.Clear();
+ GC.SuppressFinalize(this);
+ Disposed = true;
+ }
+ }
+
+ ///<inheritdoc/>
+ protected override object GetResource()
+ {
+ //Update user-data
+ Entry.UserData = JsonSerializer.SerializeToUtf8Bytes(Properties.Value, SR_OPTIONS);
+ return Entry;
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/VNLib.Plugins.Essentials.Users/src/UserManager.cs b/lib/VNLib.Plugins.Essentials.Users/src/UserManager.cs
new file mode 100644
index 0000000..e8a0721
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/src/UserManager.cs
@@ -0,0 +1,417 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Users
+* File: UserManagerExport.cs
+*
+* UserManagerExport.cs is part of VNLib.Plugins.Essentials.Users which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Users is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published
+* by the Free Software Foundation, either version 2 of the License,
+* or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Users 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
+* General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with VNLib.Plugins.Essentials.Users. If not, see http://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Data;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Async;
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Users.Model;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+using VNLib.Plugins.Extensions.Loading.Users;
+
+namespace VNLib.Plugins.Essentials.Users
+{
+
+ /// <summary>
+ /// Provides SQL database backed structured user accounts
+ /// </summary>
+ [ServiceExport]
+ [ConfigurationName("users", Required = false)]
+ public sealed class UserManager : IUserManager, IAsyncResourceStateHandler
+ {
+
+ private readonly IAsyncLazy<DbContextOptions> _dbOptions;
+ private readonly IPasswordHashingProvider _passwords;
+
+ public UserManager(PluginBase plugin)
+ {
+ //Get the connection factory
+ _dbOptions = plugin.GetContextOptionsAsync();
+
+ //Load password hashing provider
+ _passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>();
+
+#pragma warning disable CA5394 // Do not use insecure randomness
+ int randomDelay = Random.Shared.Next(1000, 4000);
+#pragma warning restore CA5394 // Do not use insecure randomness
+
+ //Create tables, but give plenty of delay on startup
+ _ = plugin.ObserveWork(() => CreateDatabaseTables(plugin), randomDelay);
+ }
+
+ public UserManager(PluginBase plugin, IConfigScope config):this(plugin)
+ { }
+
+ /*
+ * Create the databases!
+ */
+ private static async Task CreateDatabaseTables(PluginBase plugin)
+ {
+ //Ensure the database is created
+ await plugin.EnsureDbCreatedAsync<UsersContext>(plugin);
+ }
+
+ ///<inheritdoc/>
+ public IPasswordHashingProvider? GetHashProvider() => _passwords;
+
+ ///<inheritdoc/>
+ public string ComputeSafeUserId(string input)
+ {
+ return ManagedHash.ComputeHash(input, HashAlg.SHA1, HashEncodingMode.Hexadecimal);
+ }
+
+ private static string GetSafeRandomId()
+ {
+ return RandomHash.GetRandomHash(HashAlg.SHA1, 64, HashEncodingMode.Hexadecimal);
+ }
+
+ ///<inheritdoc/>
+ public async Task<IUser> CreateUserAsync(IUserCreationRequest creation, string? userId, CancellationToken cancellation = default)
+ {
+ ArgumentNullException.ThrowIfNull(creation);
+
+ //Set random user-id if not set
+ userId ??= GetSafeRandomId();
+ ArgumentException.ThrowIfNullOrWhiteSpace(creation.Username, nameof(creation.Username));
+
+ PrivateString? hash = null;
+
+ /*
+ * If a raw password is not required, it may be optionally left
+ * null for a random password to be generated. Otherwise the
+ * supplied password is hashed and stored.
+ */
+ if(!creation.UseRawPassword)
+ {
+ hash = creation.Password == null ?
+ _passwords.GetRandomPassword()
+ : _passwords.Hash(creation.Password);
+ }
+
+ try
+ {
+ //Init db
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //See if user exists by its id or by its email
+ bool exists = await (from s in db.Users
+ where s.Id == userId || s.UserId == creation.Username
+ select s)
+ .AnyAsync(cancellation);
+ if (exists)
+ {
+ //Rollback transaction
+ await db.SaveAndCloseAsync(false, cancellation);
+
+ throw new UserExistsException("The user already exists");
+ }
+
+ DateTime now = DateTime.UtcNow;
+
+ //Create user entry
+ UserEntry usr = new()
+ {
+ Id = userId,
+ UserId = creation.Username,
+ PrivilegeLevel = (long)creation.Privileges,
+ UserData = null,
+ Created = now,
+ LastModified = now,
+
+ //Cast private string for storage
+ PassHash = (string?)(creation.UseRawPassword ? creation.Password : hash),
+ };
+
+ //Add to user table
+ db.Users.Add(usr);
+
+ //Save changes
+ ERRNO count = await db.SaveAndCloseAsync(true, cancellation);
+
+ //Remove ref to password hash
+ usr.PassHash = null;
+
+ if (count)
+ {
+ return new UserData(this, usr)
+ {
+ Status = creation.InitialStatus
+ };
+ }
+
+ throw new UserCreationFailedException($"Failed to create the new user due to a database error. result: {count}");
+ }
+ catch (UserExistsException)
+ {
+ throw;
+ }
+ catch (UserCreationFailedException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new UserCreationFailedException("Failed to create the user account", ex);
+ }
+ finally
+ {
+ hash?.Erase();
+ }
+ }
+
+ ///<inheritdoc/>
+ public async Task<ERRNO> ValidatePasswordAsync(IUser user, PrivateString password, PassValidateFlags flags, CancellationToken cancellation = default)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(password);
+
+ //Try to get the user's password or hash
+ using PrivateString? passHash = await RecoverPasswordAsync(user, cancellation);
+
+ if(passHash is null)
+ {
+ return UserPassValResult.Null;
+ }
+
+ //See if hashing is bypassed
+ if ((flags & PassValidateFlags.BypassHashing) > 0)
+ {
+ //Compare raw passwords
+ return password.Equals(passHash)
+ ? UserPassValResult.Success
+ : UserPassValResult.Failed;
+ }
+ else
+ {
+ //Verify password hashes (usually defauly)
+ return _passwords.Verify(passHash, password)
+ ? UserPassValResult.Success
+ : UserPassValResult.Failed;
+ }
+ }
+
+ ///<inheritdoc/>
+ public async Task<PrivateString?> RecoverPasswordAsync(IUser user, CancellationToken cancellation = default)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Get a user entry that only contains the password hash and user-id
+ UserEntry? usr = await (from s in db.Users
+ where s.Id == user.UserID
+ select new UserEntry
+ {
+ Id = s.Id,
+ PassHash = s.PassHash,
+ Version = s.Version
+ })
+ .SingleOrDefaultAsync(cancellation);
+
+ //Close transactions and return
+ await db.SaveAndCloseAsync(true, cancellation);
+
+ //Convert to private string
+ return PrivateString.ToPrivateString(usr?.PassHash, true);
+ }
+
+ ///<inheritdoc/>
+ public async Task<ERRNO> UpdatePasswordAsync(IUser user, PrivateString newPass, CancellationToken cancellation = default)
+ {
+ ArgumentNullException.ThrowIfNull(newPass);
+ ArgumentException.ThrowIfNullOrEmpty((string?)newPass);
+
+ //Get the entry back from the user data object
+ UserEntry entry = user is UserData ue ? ue.Entry : throw new ArgumentException("User must be a UserData object", nameof(user));
+
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Track the entry again
+ db.Users.Attach(entry);
+
+ //Compute the new password hash
+ using PrivateString passwordHash = _passwords.Hash(newPass);
+
+ //Update password (must cast)
+ entry.PassHash = (string?)passwordHash;
+
+ //Update modified time
+ entry.LastModified = DateTime.UtcNow;
+
+ //Save changes async
+ int count = await db.SaveAndCloseAsync(true, cancellation);
+
+ //Clear the new password hash
+ entry.PassHash = null;
+
+ return count;
+ }
+
+ ///<inheritdoc/>
+ public async Task<long> GetUserCountAsync(CancellationToken cancellation = default)
+ {
+ await using UsersContext db = new(_dbOptions.Value);
+
+ long count = await db.Users.LongCountAsync(cancellation);
+
+ await db.SaveAndCloseAsync(true, cancellation);
+
+ return count;
+ }
+
+ [Obsolete("Removed in favor of GetUserFromUsernameAsync, transition away from email address")]
+ public Task<IUser?> GetUserFromEmailAsync(string emailAddress, CancellationToken cancellation = default)
+ => GetUserFromUsernameAsync(emailAddress, cancellation);
+
+ ///<inheritdoc/>
+ public async Task<IUser?> GetUserFromUsernameAsync(string username, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(username);
+
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Get user without password
+ UserEntry? usr = await (from s in db.Users
+ where s.UserId == username
+ select new UserEntry
+ {
+ Id = s.Id,
+ Created = s.Created,
+ UserId = s.UserId,
+ LastModified = s.LastModified,
+ PassHash = null,
+ PrivilegeLevel = s.PrivilegeLevel,
+ UserData = s.UserData,
+ Version = s.Version
+ })
+ .SingleOrDefaultAsync(cancellationToken);
+
+ //Close transactions and return
+ await db.SaveAndCloseAsync(true, cancellationToken);
+
+ return usr == null ? null : new UserData(this, usr);
+ }
+
+ ///<inheritdoc/>
+ public async Task<IUser?> GetUserFromIDAsync(string userId, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(userId);
+
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Get user without a password
+ UserEntry? usr = await (from s in db.Users
+ where s.Id == userId
+ select new UserEntry
+ {
+ Id = s.Id,
+ Created = s.Created,
+ UserId = s.UserId,
+ LastModified = s.LastModified,
+ PassHash = null,
+ PrivilegeLevel = s.PrivilegeLevel,
+ UserData = s.UserData,
+ Version = s.Version
+ })
+ .SingleOrDefaultAsync(cancellationToken);
+
+
+ //Close transactions and return
+ await db.SaveAndCloseAsync(true, cancellationToken);
+
+ return usr == null
+ ? null
+ : new UserData(this, usr);
+ }
+
+ ///<inheritdoc/>
+ async Task IAsyncResourceStateHandler.UpdateAsync(AsyncUpdatableResource resource, object state, CancellationToken cancellation)
+ {
+ //recover user-data object
+ UserEntry entry = (state as UserEntry)!;
+ ERRNO result;
+ try
+ {
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Track the entry again
+ db.Users.Attach(entry);
+
+ //Set all mutable entry modified flags
+ db.Entry(entry).Property(x => x.UserData).IsModified = true;
+ db.Entry(entry).Property(x => x.PrivilegeLevel).IsModified = true;
+
+ //Update modified time
+ entry.LastModified = DateTime.UtcNow;
+
+
+ result = await db.SaveAndCloseAsync(true, cancellation);
+ }
+ catch (Exception ex)
+ {
+ throw new UserUpdateException("", ex);
+ }
+ if (!result)
+ {
+ throw new UserUpdateException("The update operation failed because the transaction returned 0 updated records", null);
+ }
+ }
+
+ ///<inheritdoc/>
+ async Task IAsyncResourceStateHandler.DeleteAsync(AsyncUpdatableResource resource, CancellationToken cancellation)
+ {
+ //recover user-data object
+ UserData user = (resource as UserData)!;
+ ERRNO result;
+ try
+ {
+ await using UsersContext db = new(_dbOptions.Value);
+
+ //Delete the user from the database
+ db.Users.Remove(user.Entry);
+
+ result = await db.SaveAndCloseAsync(true, cancellation);
+ }
+ catch (Exception ex)
+ {
+ throw new UserDeleteException("Failed to delete the user entry from the database", ex);
+ }
+ if (!result)
+ {
+ throw new UserDeleteException("Failed to delete the user account because of a database failure, the user may already be deleted", null);
+ }
+ }
+
+ }
+}
diff --git a/lib/VNLib.Plugins.Essentials.Users/src/VNLib.Plugins.Essentials.Users.csproj b/lib/VNLib.Plugins.Essentials.Users/src/VNLib.Plugins.Essentials.Users.csproj
new file mode 100644
index 0000000..eed0bba
--- /dev/null
+++ b/lib/VNLib.Plugins.Essentials.Users/src/VNLib.Plugins.Essentials.Users.csproj
@@ -0,0 +1,65 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <AssemblyName>VNLib.Plugins.Essentials.Users</AssemblyName>
+ <RootNamespace>VNLib.Plugins.Essentials.Users</RootNamespace>
+ <GenerateDocumentationFile>True</GenerateDocumentationFile>
+ <!--Enable dynamic loading-->
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <AnalysisLevel Condition="'$(BuildingInsideVisualStudio)' == true">latest-all</AnalysisLevel>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageId>VNLib.Plugins.Essentials.Users</PackageId>
+ <Authors>Vaughn Nugent</Authors>
+ <Company>Vaughn Nugent</Company>
+ <Product>VNLib.Plugins.Essentials.Users</Product>
+ <Copyright>Copyright © 2024 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials</PackageProjectUrl>
+ <RepositoryUrl>https://github.com/VnUgE/Plugins.Essentials/tree/master/lib/VNLib.Plugins.Essentials.Users</RepositoryUrl>
+ <Description>Provides a runtime loadable IUserManager and IUser implementation, backed by an SQL database. This project may be stored in your plugin's Assets directory to be loaded at runtime by the Extensions.Loading.UserManger singleton.</Description>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <PackageLicenseFile>LICENSE</PackageLicenseFile>
+ <RequireLicenseAcceptance>True</RequireLicenseAcceptance>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="..\..\..\LICENSE">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ <None Include="..\README.md">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\core\lib\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Data\src\VNLib.Plugins.Extensions.Data.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Loading.Sql\src\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Loading\src\VNLib.Plugins.Extensions.Loading.csproj" />
+ </ItemGroup>
+
+ <Target Condition="'$(BuildingInsideVisualStudio)' == true" Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;$(SolutionDir)devplugins\RuntimeAssets\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/plugins.essentials.build.sln b/plugins.essentials.build.sln
index 3211b22..473a1d2 100644
--- a/plugins.essentials.build.sln
+++ b/plugins.essentials.build.sln
@@ -33,6 +33,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Au
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Accounts.AppData", "plugins\VNLib.Plugins.Essentials.Accounts.AppData\src\VNLib.Plugins.Essentials.Accounts.AppData.csproj", "{CCA18B6A-491F-424A-9104-6D399D9CB775}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{695430DB-0563-4785-987D-2F895CC3CE0C}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Users", "lib\VNLib.Plugins.Essentials.Users\src\VNLib.Plugins.Essentials.Users.csproj", "{4CDD9D90-1013-442F-B651-9604B69B60C1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +75,10 @@ Global
{CCA18B6A-491F-424A-9104-6D399D9CB775}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CCA18B6A-491F-424A-9104-6D399D9CB775}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CCA18B6A-491F-424A-9104-6D399D9CB775}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4CDD9D90-1013-442F-B651-9604B69B60C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4CDD9D90-1013-442F-B651-9604B69B60C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4CDD9D90-1013-442F-B651-9604B69B60C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4CDD9D90-1013-442F-B651-9604B69B60C1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -79,6 +87,7 @@ Global
{6E54935B-DD2E-4EF0-9382-F64D36616442} = {FF99AAA6-D1E2-4C4E-B5EC-2A12C7A0E15A}
{7429F297-562F-4830-88C1-4F32B8D0A749} = {FF99AAA6-D1E2-4C4E-B5EC-2A12C7A0E15A}
{B192FDBC-D22A-49CF-85C7-F421E3FA1B25} = {FF99AAA6-D1E2-4C4E-B5EC-2A12C7A0E15A}
+ {4CDD9D90-1013-442F-B651-9604B69B60C1} = {695430DB-0563-4785-987D-2F895CC3CE0C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BA2844FD-C565-4DB1-93B9-B110C6EEB3DC}