diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index fe945f1..4d674dc 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; @@ -92,7 +93,7 @@ public class AdminController : Controller /// [HttpPost] [Route("update-user")] - public async Task UpdateUser([FromBody] PrivateVoidUser user) + public async Task UpdateUser([FromBody] PrivateUser user) { var oldUser = await _userStore.Get(user.Id); if (oldUser == default) return BadRequest(); @@ -101,5 +102,5 @@ public class AdminController : Controller return Ok(); } - public record AdminListedUser(PrivateVoidUser User, int Uploads); + public record AdminListedUser(PrivateUser User, int Uploads); } diff --git a/VoidCat/Controllers/AuthController.cs b/VoidCat/Controllers/AuthController.cs index 082eae0..dcceda0 100644 --- a/VoidCat/Controllers/AuthController.cs +++ b/VoidCat/Controllers/AuthController.cs @@ -5,27 +5,30 @@ using System.Text; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; +using VoidCat.Services.Users; namespace VoidCat.Controllers; [Route("auth")] public class AuthController : Controller { - private readonly IUserManager _manager; + private readonly UserManager _manager; private readonly VoidSettings _settings; private readonly ICaptchaVerifier _captchaVerifier; private readonly IApiKeyStore _apiKeyStore; private readonly IUserStore _userStore; - public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier, IApiKeyStore apiKeyStore, - IUserStore userStore1) + public AuthController(UserManager userManager, VoidSettings settings, ICaptchaVerifier captchaVerifier, + IApiKeyStore apiKeyStore, + IUserStore userStore) { - _manager = userStore; + _manager = userManager; _settings = settings; _captchaVerifier = captchaVerifier; _apiKeyStore = apiKeyStore; - _userStore = userStore1; + _userStore = userStore; } /// @@ -96,6 +99,35 @@ public class AuthController : Controller } } + /// + /// Start OAuth2 authorize flow + /// + /// OAuth provider + /// + [HttpGet] + [Route("{provider}")] + public IActionResult Authorize([FromRoute] string provider) + { + return Redirect(_manager.Authorize(provider).ToString()); + } + + /// + /// Authorize user from OAuth2 code grant + /// + /// Code used to generate access token + /// OAuth provider + /// + [HttpGet] + [Route("{provider}/token")] + public async Task Token([FromRoute] string provider, [FromQuery] string code) + { + var newUser = await _manager.LoginOrRegister(code, provider); + var token = CreateToken(newUser, DateTime.UtcNow.AddHours(12)); + var tokenWriter = new JwtSecurityTokenHandler(); + + return Redirect($"/login#{tokenWriter.WriteToken(token)}"); + } + /// /// List api keys for the user /// @@ -145,7 +177,7 @@ public class AuthController : Controller return Json(key); } - private JwtSecurityToken CreateToken(VoidUser user, DateTime expiry) + private JwtSecurityToken CreateToken(User user, DateTime expiry) { var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); @@ -171,19 +203,14 @@ public class AuthController : Controller Password = password; } - [Required] - [EmailAddress] - public string Username { get; } + [Required] [EmailAddress] public string Username { get; } - [Required] - [MinLength(6)] - public string Password { get; } + [Required] [MinLength(6)] public string Password { get; } public string? Captcha { get; init; } } - public sealed record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null); - + public sealed record LoginResponse(string? Jwt, string? Error = null, User? Profile = null); public sealed record CreateApiKeyRequest(DateTime Expiry); -} +} \ No newline at end of file diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 4ccf29c..962eccb 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.StaticFiles; using Newtonsoft.Json; using VoidCat.Model; using VoidCat.Model.Payments; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; @@ -73,7 +74,7 @@ namespace VoidCat.Controllers var store = _settings.DefaultFileStore; if (uid.HasValue) { - var user = await _userStore.Get(uid.Value); + var user = await _userStore.Get(uid.Value); if (user?.Storage != default) { store = user.Storage!; diff --git a/VoidCat/Controllers/UserController.cs b/VoidCat/Controllers/UserController.cs index e5f7cb8..af2dfbf 100644 --- a/VoidCat/Controllers/UserController.cs +++ b/VoidCat/Controllers/UserController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; @@ -42,14 +43,14 @@ public class UserController : Controller var requestedId = isMe ? loggedUser!.Value : id.FromBase58Guid(); if (loggedUser == requestedId) { - var pUser = await _store.Get(requestedId); + var pUser = await _store.Get(requestedId); if (pUser == default) return NotFound(); return Json(pUser); } - var user = await _store.Get(requestedId); - if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return NotFound(); + var user = await _store.Get(requestedId); + if (!(user?.Flags.HasFlag(UserFlags.PublicProfile) ?? false)) return NotFound(); return Json(user); } @@ -62,12 +63,12 @@ public class UserController : Controller /// /// [HttpPost] - public async Task UpdateUser([FromRoute] string id, [FromBody] PublicVoidUser user) + public async Task UpdateUser([FromRoute] string id, [FromBody] PublicUser user) { var loggedUser = await GetAuthorizedUser(id); if (loggedUser == default) return Unauthorized(); - if (!loggedUser.Flags.HasFlag(VoidUserFlags.EmailVerified)) return Forbid(); + if (!loggedUser.Flags.HasFlag(UserFlags.EmailVerified)) return Forbid(); await _store.UpdateProfile(user); return Ok(); @@ -97,7 +98,7 @@ public class UserController : Controller // not logged in user files, check public flag var canViewUploads = loggedUser == user.Id || isAdmin; if (!canViewUploads && - !user.Flags.HasFlag(VoidUserFlags.PublicUploads)) return Forbid(); + !user.Flags.HasFlag(UserFlags.PublicUploads)) return Forbid(); var results = await _userUploads.ListFiles(id.FromBase58Guid(), request); var files = await results.Results.ToListAsync(); @@ -123,7 +124,7 @@ public class UserController : Controller var user = await GetAuthorizedUser(id); if (user == default) return Unauthorized(); - var isEmailVerified = (user?.Flags.HasFlag(VoidUserFlags.EmailVerified) ?? false); + var isEmailVerified = (user?.Flags.HasFlag(UserFlags.EmailVerified) ?? false); if (isEmailVerified) return UnprocessableEntity(); await _emailVerification.SendNewCode(user!); @@ -146,22 +147,22 @@ public class UserController : Controller var token = code.FromBase58Guid(); if (!await _emailVerification.VerifyCode(user, token)) return BadRequest(); - user.Flags |= VoidUserFlags.EmailVerified; + user.Flags |= UserFlags.EmailVerified; await _store.UpdateProfile(user.ToPublic()); return Accepted(); } - private async Task GetAuthorizedUser(string id) + private async Task GetAuthorizedUser(string id) { var loggedUser = HttpContext.GetUserId(); var gid = id.FromBase58Guid(); - var user = await _store.Get(gid); + var user = await _store.Get(gid); return user?.Id != loggedUser ? default : user; } - private async Task GetRequestedUser(string id) + private async Task GetRequestedUser(string id) { var gid = id.FromBase58Guid(); - return await _store.Get(gid); + return await _store.Get(gid); } } diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index bbc3565..5047f62 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -5,6 +5,7 @@ using Amazon; using Amazon.Runtime; using Amazon.S3; using VoidCat.Model.Exceptions; +using VoidCat.Model.User; namespace VoidCat.Model; @@ -219,8 +220,18 @@ public static class Extensions throw new ArgumentException("Unknown algo", nameof(algo)); } - public static bool CheckPassword(this InternalVoidUser vu, string password) + /// + /// Validate password matches hashed password + /// + /// + /// + /// + /// + public static bool CheckPassword(this InternalUser vu, string password) { + if (vu.AuthType != AuthType.Internal) + throw new InvalidOperationException("User type is not internal, cannot check password!"); + var hashParts = vu.Password.Split(":"); return vu.Password == password.Hash(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null); } @@ -239,4 +250,7 @@ public static class Extensions public static bool HasPlausible(this VoidSettings settings) => settings.PlausibleAnalytics?.Endpoint != null; + + public static bool HasDiscord(this VoidSettings settings) + => settings.Discord != null; } \ No newline at end of file diff --git a/VoidCat/Model/ApiKey.cs b/VoidCat/Model/User/ApiKey.cs similarity index 92% rename from VoidCat/Model/ApiKey.cs rename to VoidCat/Model/User/ApiKey.cs index 2fd6ede..d9f547b 100644 --- a/VoidCat/Model/ApiKey.cs +++ b/VoidCat/Model/User/ApiKey.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace VoidCat.Model; +namespace VoidCat.Model.User; public sealed class ApiKey { diff --git a/VoidCat/Model/User/AuthType.cs b/VoidCat/Model/User/AuthType.cs new file mode 100644 index 0000000..9d0edb2 --- /dev/null +++ b/VoidCat/Model/User/AuthType.cs @@ -0,0 +1,27 @@ +namespace VoidCat.Model.User; + +/// +/// User account authentication type +/// +public enum AuthType +{ + /// + /// Encrypted password + /// + Internal = 0, + + /// + /// PGP challenge + /// + PGP = 1, + + /// + /// OAuth2 token + /// + OAuth2 = 2, + + /// + /// Lightning node challenge + /// + Lightning = 3 +} \ No newline at end of file diff --git a/VoidCat/Model/User/InternalUser.cs b/VoidCat/Model/User/InternalUser.cs new file mode 100644 index 0000000..7ead244 --- /dev/null +++ b/VoidCat/Model/User/InternalUser.cs @@ -0,0 +1,12 @@ +namespace VoidCat.Model.User; + +/// +/// Internal user object used by the system +/// +public sealed class InternalUser : PrivateUser +{ + /// + /// A password hash for the user in the format + /// + public string Password { get; init; } = null!; +} \ No newline at end of file diff --git a/VoidCat/Model/User/PrivateUser.cs b/VoidCat/Model/User/PrivateUser.cs new file mode 100644 index 0000000..12bc51d --- /dev/null +++ b/VoidCat/Model/User/PrivateUser.cs @@ -0,0 +1,17 @@ +namespace VoidCat.Model.User; + +/// +/// A user object which includes the Email +/// +public class PrivateUser : User +{ + /// + /// Users email address + /// + public string Email { get; set; } = null!; + + /// + /// Users storage system for new uploads + /// + public string? Storage { get; set; } +} \ No newline at end of file diff --git a/VoidCat/Model/User/PublicUser.cs b/VoidCat/Model/User/PublicUser.cs new file mode 100644 index 0000000..13768c1 --- /dev/null +++ b/VoidCat/Model/User/PublicUser.cs @@ -0,0 +1,6 @@ +namespace VoidCat.Model.User; + +/// +public sealed class PublicUser : User +{ +} \ No newline at end of file diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/User/User.cs similarity index 56% rename from VoidCat/Model/VoidUser.cs rename to VoidCat/Model/User/User.cs index e116734..b9e0ba1 100644 --- a/VoidCat/Model/VoidUser.cs +++ b/VoidCat/Model/User/User.cs @@ -1,13 +1,11 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; -// ReSharper disable InconsistentNaming - -namespace VoidCat.Model; +namespace VoidCat.Model.User; /// /// The base user object for the system /// -public abstract class VoidUser +public abstract class User { /// /// Unique Id of the user @@ -43,13 +41,18 @@ public abstract class VoidUser /// /// Profile flags /// - public VoidUserFlags Flags { get; set; } = VoidUserFlags.PublicProfile; + public UserFlags Flags { get; set; } = UserFlags.PublicProfile; + /// + /// Account authentication type + /// + public AuthType AuthType { get; init; } + /// /// Returns the Public object for this user /// /// - public PublicVoidUser ToPublic() + public PublicUser ToPublic() { return new() { @@ -61,44 +64,4 @@ public abstract class VoidUser Flags = Flags }; } -} - -/// -/// Internal user object used by the system -/// -public sealed class InternalVoidUser : PrivateVoidUser -{ - /// - /// A password hash for the user in the format - /// - public string Password { get; init; } = null!; -} - -/// -/// A user object which includes the Email -/// -public class PrivateVoidUser : VoidUser -{ - /// - /// Users email address - /// - public string Email { get; set; } = null!; - - /// - /// Users storage system for new uploads - /// - public string? Storage { get; set; } -} - -/// -public sealed class PublicVoidUser : VoidUser -{ -} - -[Flags] -public enum VoidUserFlags -{ - PublicProfile = 1, - PublicUploads = 2, - EmailVerified = 4 } \ No newline at end of file diff --git a/VoidCat/Model/User/UserAuthToken.cs b/VoidCat/Model/User/UserAuthToken.cs new file mode 100644 index 0000000..774b29f --- /dev/null +++ b/VoidCat/Model/User/UserAuthToken.cs @@ -0,0 +1,23 @@ +namespace VoidCat.Model.User; + +/// +/// OAuth2 access token +/// +public sealed class UserAuthToken +{ + public Guid Id { get; init; } + + public Guid User { get; init; } + + public string Provider { get; init; } + + public string AccessToken { get; init; } + + public string TokenType { get; init; } + + public DateTime Expires { get; init; } + + public string RefreshToken { get; init; } + + public string Scope { get; init; } +} \ No newline at end of file diff --git a/VoidCat/Model/User/UserFlags.cs b/VoidCat/Model/User/UserFlags.cs new file mode 100644 index 0000000..2d23f4f --- /dev/null +++ b/VoidCat/Model/User/UserFlags.cs @@ -0,0 +1,23 @@ +namespace VoidCat.Model.User; + +/// +/// Account status flags +/// +[Flags] +public enum UserFlags +{ + /// + /// Profile is public + /// + PublicProfile = 1, + + /// + /// Uploads list is public + /// + PublicUploads = 2, + + /// + /// Account has email verified + /// + EmailVerified = 4 +} \ No newline at end of file diff --git a/VoidCat/Model/VoidFile.cs b/VoidCat/Model/VoidFile.cs index eeb7052..d712ce3 100644 --- a/VoidCat/Model/VoidFile.cs +++ b/VoidCat/Model/VoidFile.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using VoidCat.Model.Payments; +using VoidCat.Model.User; namespace VoidCat.Model { @@ -24,7 +25,7 @@ namespace VoidCat.Model /// /// User profile that uploaded the file /// - public PublicVoidUser? Uploader { get; init; } + public PublicUser? Uploader { get; init; } /// /// Traffic stats for this file diff --git a/VoidCat/Model/VoidSettings.cs b/VoidCat/Model/VoidSettings.cs index f3cb150..7182bc1 100644 --- a/VoidCat/Model/VoidSettings.cs +++ b/VoidCat/Model/VoidSettings.cs @@ -7,6 +7,11 @@ namespace VoidCat.Model /// public class VoidSettings { + /// + /// Base site url, used for redirect urls + /// + public Uri SiteUrl { get; init; } + /// /// Data directory to store files in /// @@ -15,7 +20,7 @@ namespace VoidCat.Model /// /// Size in bytes to split uploads into chunks /// - public ulong? UploadSegmentSize { get; init; } = null; + public ulong? UploadSegmentSize { get; init; } /// /// Tor configuration @@ -90,11 +95,16 @@ namespace VoidCat.Model /// Select which store to use for files storage, if not set "local-disk" will be used /// public string DefaultFileStore { get; init; } = "local-disk"; - + /// /// Plausible Analytics endpoint url /// public PlausibleSettings? PlausibleAnalytics { get; init; } + + /// + /// Discord application settings + /// + public DiscordSettings? Discord { get; init; } } public sealed class TorSettings @@ -169,4 +179,10 @@ namespace VoidCat.Model public Uri? Endpoint { get; init; } public string? Domain { get; init; } } + + public sealed class DiscordSettings + { + public string? ClientId { get; init; } + public string? ClientSecret { get; init; } + } } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IApiKeyStore.cs b/VoidCat/Services/Abstractions/IApiKeyStore.cs index b063632..c0ebb0c 100644 --- a/VoidCat/Services/Abstractions/IApiKeyStore.cs +++ b/VoidCat/Services/Abstractions/IApiKeyStore.cs @@ -1,4 +1,4 @@ -using VoidCat.Model; +using VoidCat.Model.User; namespace VoidCat.Services.Abstractions; diff --git a/VoidCat/Services/Abstractions/IBasicStore.cs b/VoidCat/Services/Abstractions/IBasicStore.cs index 169ce69..f42da90 100644 --- a/VoidCat/Services/Abstractions/IBasicStore.cs +++ b/VoidCat/Services/Abstractions/IBasicStore.cs @@ -12,14 +12,7 @@ public interface IBasicStore /// /// ValueTask Get(Guid id); - - /// - /// Get multiple items from the store - /// - /// - /// - ValueTask> Get(Guid[] ids); - + /// /// Add an item to the store /// diff --git a/VoidCat/Services/Abstractions/IEmailVerification.cs b/VoidCat/Services/Abstractions/IEmailVerification.cs index 8accfe3..67137b9 100644 --- a/VoidCat/Services/Abstractions/IEmailVerification.cs +++ b/VoidCat/Services/Abstractions/IEmailVerification.cs @@ -1,10 +1,11 @@ using VoidCat.Model; +using VoidCat.Model.User; namespace VoidCat.Services.Abstractions; public interface IEmailVerification { - ValueTask SendNewCode(PrivateVoidUser user); + ValueTask SendNewCode(PrivateUser user); - ValueTask VerifyCode(PrivateVoidUser user, Guid code); + ValueTask VerifyCode(PrivateUser user, Guid code); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IOAuthProvider.cs b/VoidCat/Services/Abstractions/IOAuthProvider.cs new file mode 100644 index 0000000..9efa80b --- /dev/null +++ b/VoidCat/Services/Abstractions/IOAuthProvider.cs @@ -0,0 +1,34 @@ +using VoidCat.Model.User; + +namespace VoidCat.Services.Abstractions; + +/// +/// OAuth2 code grant provider +/// +public interface IOAuthProvider +{ + /// + /// Id of this provider + /// + string Id { get; } + + /// + /// Generate authorization code grant uri + /// + /// + Uri Authorize(); + + /// + /// Get access token from auth code + /// + /// + /// + ValueTask GetToken(string code); + + /// + /// Get a user object which represents this external account authorization + /// + /// + /// + ValueTask GetUserDetails(UserAuthToken token); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserAuthTokenStore.cs b/VoidCat/Services/Abstractions/IUserAuthTokenStore.cs new file mode 100644 index 0000000..1ac8f47 --- /dev/null +++ b/VoidCat/Services/Abstractions/IUserAuthTokenStore.cs @@ -0,0 +1,10 @@ +using VoidCat.Model.User; + +namespace VoidCat.Services.Abstractions; + +/// +/// User access token store +/// +public interface IUserAuthTokenStore : IBasicStore +{ +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserManager.cs b/VoidCat/Services/Abstractions/IUserManager.cs deleted file mode 100644 index 615b5f4..0000000 --- a/VoidCat/Services/Abstractions/IUserManager.cs +++ /dev/null @@ -1,9 +0,0 @@ -using VoidCat.Model; - -namespace VoidCat.Services.Abstractions; - -public interface IUserManager -{ - ValueTask Login(string username, string password); - ValueTask Register(string username, string password); -} diff --git a/VoidCat/Services/Abstractions/IUserStore.cs b/VoidCat/Services/Abstractions/IUserStore.cs index a0ac290..d1a7f62 100644 --- a/VoidCat/Services/Abstractions/IUserStore.cs +++ b/VoidCat/Services/Abstractions/IUserStore.cs @@ -1,11 +1,12 @@ using VoidCat.Model; +using VoidCat.Model.User; namespace VoidCat.Services.Abstractions; /// /// User store /// -public interface IUserStore : IPublicPrivateStore +public interface IUserStore : IPublicPrivateStore { /// /// Get a single user @@ -13,7 +14,7 @@ public interface IUserStore : IPublicPrivateStore /// /// /// - ValueTask Get(Guid id) where T : VoidUser; + ValueTask Get(Guid id) where T : User; /// /// Lookup a user by their email address @@ -27,14 +28,14 @@ public interface IUserStore : IPublicPrivateStore /// /// /// - ValueTask> ListUsers(PagedRequest request); + ValueTask> ListUsers(PagedRequest request); /// /// Update a users profile /// /// /// - ValueTask UpdateProfile(PublicVoidUser newUser); + ValueTask UpdateProfile(PublicUser newUser); /// /// Updates the last login timestamp for the user @@ -49,5 +50,5 @@ public interface IUserStore : IPublicPrivateStore /// /// /// - ValueTask AdminUpdateUser(PrivateVoidUser user); + ValueTask AdminUpdateUser(PrivateUser user); } \ No newline at end of file diff --git a/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs b/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs index ef59ec0..0ab20d0 100644 --- a/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs +++ b/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs @@ -1,4 +1,5 @@ using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; @@ -31,7 +32,7 @@ public class DeleteUnverifiedAccounts : BackgroundService await foreach (var account in accounts.Results.WithCancellation(stoppingToken)) { - if (!account.Flags.HasFlag(VoidUserFlags.EmailVerified) && + if (!account.Flags.HasFlag(UserFlags.EmailVerified) && account.Created.AddDays(7) < DateTimeOffset.UtcNow) { _logger.LogInformation("Deleting un-verified account: {Id}", account.Id.ToBase58()); diff --git a/VoidCat/Services/BasicCacheStore.cs b/VoidCat/Services/BasicCacheStore.cs index 4e02ab6..1b5f998 100644 --- a/VoidCat/Services/BasicCacheStore.cs +++ b/VoidCat/Services/BasicCacheStore.cs @@ -5,45 +5,29 @@ namespace VoidCat.Services; /// public abstract class BasicCacheStore : IBasicStore { - protected readonly ICache Cache; + protected readonly ICache _cache; protected BasicCacheStore(ICache cache) { - Cache = cache; + _cache = cache; } /// public virtual ValueTask Get(Guid id) { - return Cache.Get(MapKey(id)); - } - - /// - public virtual async ValueTask> Get(Guid[] ids) - { - var ret = new List(); - foreach (var id in ids) - { - var r = await Cache.Get(MapKey(id)); - if (r != null) - { - ret.Add(r); - } - } - - return ret; + return _cache.Get(MapKey(id)); } /// public virtual ValueTask Add(Guid id, TStore obj) { - return Cache.Set(MapKey(id), obj); + return _cache.Set(MapKey(id), obj); } /// public virtual ValueTask Delete(Guid id) { - return Cache.Delete(MapKey(id)); + return _cache.Delete(MapKey(id)); } /// diff --git a/VoidCat/Services/Files/FileInfoManager.cs b/VoidCat/Services/Files/FileInfoManager.cs index 1895ca6..96c2ea8 100644 --- a/VoidCat/Services/Files/FileInfoManager.cs +++ b/VoidCat/Services/Files/FileInfoManager.cs @@ -1,4 +1,5 @@ using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; @@ -91,7 +92,7 @@ public sealed class FileInfoManager await Task.WhenAll(meta.AsTask(), payment.AsTask(), bandwidth.AsTask(), virusScan.AsTask(), uploader.AsTask()); if (meta.Result == default) return default; - var user = uploader.Result.HasValue ? await _userStore.Get(uploader.Result.Value) : null; + var user = uploader.Result.HasValue ? await _userStore.Get(uploader.Result.Value) : null; return new TFile() { @@ -99,7 +100,7 @@ public sealed class FileInfoManager Metadata = meta.Result, Payment = payment.Result, Bandwidth = bandwidth.Result, - Uploader = user?.Flags.HasFlag(VoidUserFlags.PublicProfile) == true ? user : null, + Uploader = user?.Flags.HasFlag(UserFlags.PublicProfile) == true ? user : null, VirusScan = virusScan.Result }; } diff --git a/VoidCat/Services/Migrations/Database/00-Init.cs b/VoidCat/Services/Migrations/Database/00-Init.cs index a4fb587..47f537a 100644 --- a/VoidCat/Services/Migrations/Database/00-Init.cs +++ b/VoidCat/Services/Migrations/Database/00-Init.cs @@ -1,6 +1,6 @@ using System.Data; using FluentMigrator; -using VoidCat.Model; +using VoidCat.Model.User; namespace VoidCat.Services.Migrations.Database; @@ -11,13 +11,13 @@ public class Init : Migration { Create.Table("Users") .WithColumn("Id").AsGuid().PrimaryKey() - .WithColumn("Email").AsString().NotNullable().Indexed() + .WithColumn("Email").AsString().Indexed() .WithColumn("Password").AsString() .WithColumn("Created").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime) .WithColumn("LastLogin").AsDateTimeOffset().Nullable() .WithColumn("Avatar").AsString().Nullable() .WithColumn("DisplayName").AsString().WithDefaultValue("void user") - .WithColumn("Flags").AsInt32().WithDefaultValue((int) VoidUserFlags.PublicProfile); + .WithColumn("Flags").AsInt32().WithDefaultValue((int) UserFlags.PublicProfile); Create.Table("Files") .WithColumn("Id").AsGuid().PrimaryKey() diff --git a/VoidCat/Services/Migrations/Database/05-AccountTypes.cs b/VoidCat/Services/Migrations/Database/05-AccountTypes.cs new file mode 100644 index 0000000..e447866 --- /dev/null +++ b/VoidCat/Services/Migrations/Database/05-AccountTypes.cs @@ -0,0 +1,44 @@ +using System.Data; +using FluentMigrator; + +namespace VoidCat.Services.Migrations.Database; + +[Migration(20220907_2015)] +public class AccountTypes : Migration +{ + public override void Up() + { + Create.Column("AuthType") + .OnTable("Users") + .AsInt16() + .WithDefaultValue(0); + + Alter.Column("Password") + .OnTable("Users") + .AsString() + .Nullable(); + + Create.Table("UsersAuthToken") + .WithColumn("Id").AsGuid().PrimaryKey() + .WithColumn("User").AsGuid().ForeignKey("Users", "Id").OnDelete(Rule.Cascade).Indexed() + .WithColumn("Provider").AsString() + .WithColumn("AccessToken").AsString() + .WithColumn("TokenType").AsString() + .WithColumn("Expires").AsDateTimeOffset() + .WithColumn("RefreshToken").AsString() + .WithColumn("Scope").AsString(); + } + + public override void Down() + { + Delete.Column("Type") + .FromTable("Users"); + + Alter.Column("Password") + .OnTable("Users") + .AsString() + .NotNullable(); + + Delete.Table("UsersAuthToken"); + } +} \ No newline at end of file diff --git a/VoidCat/Services/Migrations/MigrateToPostgres.cs b/VoidCat/Services/Migrations/MigrateToPostgres.cs index b256c71..15c3e10 100644 --- a/VoidCat/Services/Migrations/MigrateToPostgres.cs +++ b/VoidCat/Services/Migrations/MigrateToPostgres.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using Newtonsoft.Json; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; using VoidCat.Services.Payment; @@ -132,7 +133,7 @@ public class MigrateToPostgres : IMigration var privateUser = await cacheUsers.Get(user.Id); privateUser!.Password ??= privateUser.PasswordHash; - await _userStore.Set(privateUser!.Id, new InternalVoidUser() + await _userStore.Set(privateUser!.Id, new InternalUser() { Id = privateUser.Id, Avatar = privateUser.Avatar, @@ -154,7 +155,7 @@ public class MigrateToPostgres : IMigration } } - private class PrivateUser : PrivateVoidUser + private class PrivateUser : Model.User.PrivateUser { public string? PasswordHash { get; set; } public string? Password { get; set; } diff --git a/VoidCat/Services/Payment/CachePaymentStore.cs b/VoidCat/Services/Payment/CachePaymentStore.cs index 60b626b..44da897 100644 --- a/VoidCat/Services/Payment/CachePaymentStore.cs +++ b/VoidCat/Services/Payment/CachePaymentStore.cs @@ -14,11 +14,11 @@ public class CachePaymentStore : BasicCacheStore, IPaymentStore /// public override async ValueTask Get(Guid id) { - var cfg = await Cache.Get(MapKey(id)); + var cfg = await _cache.Get(MapKey(id)); return cfg?.Service switch { PaymentServices.None => cfg, - PaymentServices.Strike => await Cache.Get(MapKey(id)), + PaymentServices.Strike => await _cache.Get(MapKey(id)), _ => default }; } diff --git a/VoidCat/Services/Users/Auth/CacheUserAuthTokenStore.cs b/VoidCat/Services/Users/Auth/CacheUserAuthTokenStore.cs new file mode 100644 index 0000000..4f5c30e --- /dev/null +++ b/VoidCat/Services/Users/Auth/CacheUserAuthTokenStore.cs @@ -0,0 +1,15 @@ +using VoidCat.Model.User; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users.Auth; + +/// +public class CacheUserAuthTokenStore : BasicCacheStore, IUserAuthTokenStore +{ + public CacheUserAuthTokenStore(ICache cache) : base(cache) + { + } + + /// + protected override string MapKey(Guid id) => $"auth-token:{id}"; +} \ No newline at end of file diff --git a/VoidCat/Services/Users/Auth/DiscordOAuthProvider.cs b/VoidCat/Services/Users/Auth/DiscordOAuthProvider.cs new file mode 100644 index 0000000..c41d710 --- /dev/null +++ b/VoidCat/Services/Users/Auth/DiscordOAuthProvider.cs @@ -0,0 +1,118 @@ +using Newtonsoft.Json; +using VoidCat.Model; +using VoidCat.Model.User; + +namespace VoidCat.Services.Users.Auth; + +/// +public class DiscordOAuthProvider : GenericOAuth2Service +{ + private readonly HttpClient _client; + private readonly DiscordSettings _discord; + private readonly Uri _site; + + public DiscordOAuthProvider(HttpClient client, VoidSettings settings) : base(client) + { + _client = client; + _discord = settings.Discord!; + _site = settings.SiteUrl; + } + + /// + public override string Id => "discord"; + + /// + public override async ValueTask GetUserDetails(UserAuthToken token) + { + var req = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); + req.Headers.Authorization = new("Bearer", token.AccessToken); + + var rsp = await _client.SendAsync(req); + if (rsp.IsSuccessStatusCode) + { + var user = JsonConvert.DeserializeObject(await rsp.Content.ReadAsStringAsync()); + return new() + { + Id = Guid.NewGuid(), + AuthType = AuthType.OAuth2, + DisplayName = $"{user!.Username}", + Avatar = !string.IsNullOrEmpty(user.Avatar) + ? $"https://cdn.discordapp.com/avatars/{user.Id}/{user.Avatar}.png" + : null, + Email = user.Email!, + Created = DateTimeOffset.UtcNow, + LastLogin = DateTimeOffset.UtcNow + }; + } + + return default; + } + + /// + protected override Dictionary BuildAuthorizeQuery() + => new() + { + {"response_type", "code"}, + {"client_id", _discord.ClientId!}, + {"scope", "email identify"}, + {"prompt", "none"}, + {"redirect_uri", new Uri(_site, $"/auth/{Id}/token").ToString()} + }; + + protected override Dictionary BuildTokenQuery(string code) + => new() + { + {"client_id", _discord.ClientId!}, + {"client_secret", _discord.ClientSecret!}, + {"grant_type", "authorization_code"}, + {"code", code}, + {"redirect_uri", new Uri(_site, $"/auth/{Id}/token").ToString()} + }; + + /// + protected override UserAuthToken TransformDto(DiscordAccessToken dto) + { + return new() + { + Id = Guid.NewGuid(), + Provider = Id, + AccessToken = dto.AccessToken, + Expires = DateTime.UtcNow.AddSeconds(dto.ExpiresIn), + TokenType = dto.TokenType, + RefreshToken = dto.RefreshToken, + Scope = dto.Scope + }; + } + + /// + protected override Uri AuthorizeEndpoint => new("https://discord.com/oauth2/authorize"); + + /// + protected override Uri TokenEndpoint => new("https://discord.com/api/oauth2/token"); +} + +public class DiscordAccessToken +{ + [JsonProperty("access_token")] public string AccessToken { get; init; } + + [JsonProperty("expires_in")] public int ExpiresIn { get; init; } + + [JsonProperty("token_type")] public string TokenType { get; init; } + + [JsonProperty("refresh_token")] public string RefreshToken { get; init; } + + [JsonProperty("scope")] public string Scope { get; init; } +} + +internal class DiscordUser +{ + [JsonProperty("id")] public string Id { get; init; } = null!; + + [JsonProperty("username")] public string Username { get; init; } = null!; + + [JsonProperty("discriminator")] public string Discriminator { get; init; } = null!; + + [JsonProperty("avatar")] public string? Avatar { get; init; } + + [JsonProperty("email")] public string? Email { get; init; } +} \ No newline at end of file diff --git a/VoidCat/Services/Users/Auth/GenericOAuth2Service.cs b/VoidCat/Services/Users/Auth/GenericOAuth2Service.cs new file mode 100644 index 0000000..d0949b6 --- /dev/null +++ b/VoidCat/Services/Users/Auth/GenericOAuth2Service.cs @@ -0,0 +1,78 @@ +using Newtonsoft.Json; +using VoidCat.Model.User; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users.Auth; + +/// +/// Generic base class for OAuth2 code grant flow +/// +public abstract class GenericOAuth2Service : IOAuthProvider +{ + private readonly HttpClient _client; + + protected GenericOAuth2Service(HttpClient client) + { + _client = client; + } + + /// + public abstract string Id { get; } + + /// + public Uri Authorize() + { + var ub = new UriBuilder(AuthorizeEndpoint) + { + Query = string.Join("&", BuildAuthorizeQuery().Select(a => $"{a.Key}={Uri.EscapeDataString(a.Value)}")) + }; + + return ub.Uri; + } + + /// + public async ValueTask GetToken(string code) + { + var form = new FormUrlEncodedContent(BuildTokenQuery(code)); + var rsp = await _client.PostAsync(TokenEndpoint, form); + var json = await rsp.Content.ReadAsStringAsync(); + if (!rsp.IsSuccessStatusCode) + { + throw new InvalidOperationException($"Failed to get token from provider: {Id}, response: {json}"); + } + var dto = JsonConvert.DeserializeObject(json); + return TransformDto(dto!); + } + + /// + public abstract ValueTask GetUserDetails(UserAuthToken token); + + /// + /// Build query args for authorize + /// + /// + protected abstract Dictionary BuildAuthorizeQuery(); + + /// + /// Build query args for token generation + /// + /// + protected abstract Dictionary BuildTokenQuery(string code); + + /// + /// Transform DTO to + /// + /// + /// + protected abstract UserAuthToken TransformDto(TDto dto); + + /// + /// Authorize url for this service + /// + protected abstract Uri AuthorizeEndpoint { get; } + + /// + /// Generate token url for this service + /// + protected abstract Uri TokenEndpoint { get; } +} \ No newline at end of file diff --git a/VoidCat/Services/Users/Auth/OAuthFactory.cs b/VoidCat/Services/Users/Auth/OAuthFactory.cs new file mode 100644 index 0000000..47d30d1 --- /dev/null +++ b/VoidCat/Services/Users/Auth/OAuthFactory.cs @@ -0,0 +1,33 @@ +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users.Auth; + +/// +/// Factory class to access specific OAuth providers +/// +public sealed class OAuthFactory +{ + private readonly IEnumerable _providers; + + public OAuthFactory(IEnumerable providers) + { + _providers = providers; + } + + /// + /// Get an OAuth provider by id + /// + /// + /// + /// + public IOAuthProvider GetProvider(string id) + { + var provider = _providers.FirstOrDefault(a => a.Id.Equals(id, StringComparison.InvariantCultureIgnoreCase)); + if (provider == default) + { + throw new Exception($"OAuth provider not found: {id}"); + } + + return provider; + } +} \ No newline at end of file diff --git a/VoidCat/Services/Users/Auth/PostgresUserAuthTokenStore.cs b/VoidCat/Services/Users/Auth/PostgresUserAuthTokenStore.cs new file mode 100644 index 0000000..7acdd1a --- /dev/null +++ b/VoidCat/Services/Users/Auth/PostgresUserAuthTokenStore.cs @@ -0,0 +1,56 @@ +using Dapper; +using VoidCat.Model.User; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users.Auth; + +/// +public class PostgresUserAuthTokenStore : IUserAuthTokenStore +{ + private readonly PostgresConnectionFactory _connection; + + public PostgresUserAuthTokenStore(PostgresConnectionFactory connection) + { + _connection = connection; + } + + /// + public async ValueTask Get(Guid id) + { + await using var conn = await _connection.Get(); + return await conn.QuerySingleOrDefaultAsync( + @"select * from ""UsersAuthToken"" where ""User"" = :id", new {id}); + } + + /// + public async ValueTask Add(Guid id, UserAuthToken obj) + { + await using var conn = await _connection.Get(); + await conn.ExecuteAsync( + @"insert into ""UsersAuthToken""(""Id"", ""User"", ""Provider"", ""AccessToken"", ""TokenType"", ""Expires"", ""RefreshToken"", ""Scope"") +values(:id, :user, :provider, :accessToken, :tokenType, :expires, :refreshToken, :scope) +on conflict(""Id"") do update set +""AccessToken"" = :accessToken, +""TokenType"" = :tokenType, +""Expires"" = :expires, +""RefreshToken"" = :refreshToken, +""Scope"" = :scope", new + { + id = obj.Id, + user = obj.User, + provider = obj.Provider, + accessToken = obj.AccessToken, + tokenType = obj.TokenType, + expires = obj.Expires.ToUniversalTime(), + refreshToken = obj.RefreshToken, + scope = obj.Scope + }); + } + + /// + public async ValueTask Delete(Guid id) + { + await using var conn = await _connection.Get(); + await conn.ExecuteAsync(@"delete from ""UsersAuthToken"" where ""Id"" = :id", new {id}); + } +} \ No newline at end of file diff --git a/VoidCat/Services/Users/BaseEmailVerification.cs b/VoidCat/Services/Users/BaseEmailVerification.cs index 05ac305..dd2f346 100644 --- a/VoidCat/Services/Users/BaseEmailVerification.cs +++ b/VoidCat/Services/Users/BaseEmailVerification.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Mail; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; @@ -22,7 +23,7 @@ public abstract class BaseEmailVerification : IEmailVerification } /// - public async ValueTask SendNewCode(PrivateVoidUser user) + public async ValueTask SendNewCode(PrivateUser user) { var token = new EmailVerificationCode(user.Id, Guid.NewGuid(), DateTime.UtcNow.AddHours(HoursExpire)); await SaveToken(token); @@ -59,7 +60,7 @@ public abstract class BaseEmailVerification : IEmailVerification } /// - public async ValueTask VerifyCode(PrivateVoidUser user, Guid code) + public async ValueTask VerifyCode(PrivateUser user, Guid code) { var token = await GetToken(user.Id, code); if (token == default) return false; diff --git a/VoidCat/Services/Users/CacheApiKeyStore.cs b/VoidCat/Services/Users/CacheApiKeyStore.cs index ff56ca0..d15de60 100644 --- a/VoidCat/Services/Users/CacheApiKeyStore.cs +++ b/VoidCat/Services/Users/CacheApiKeyStore.cs @@ -1,4 +1,4 @@ -using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; diff --git a/VoidCat/Services/Users/CacheUserStore.cs b/VoidCat/Services/Users/CacheUserStore.cs index 716e810..31101cf 100644 --- a/VoidCat/Services/Users/CacheUserStore.cs +++ b/VoidCat/Services/Users/CacheUserStore.cs @@ -1,4 +1,5 @@ using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; @@ -21,25 +22,25 @@ public class CacheUserStore : IUserStore } /// - public ValueTask Get(Guid id) where T : VoidUser + public ValueTask Get(Guid id) where T : User { return _cache.Get(MapKey(id)); } /// - public ValueTask Get(Guid id) + public ValueTask Get(Guid id) { - return Get(id); + return Get(id); } /// - public ValueTask GetPrivate(Guid id) + public ValueTask GetPrivate(Guid id) { - return Get(id); + return Get(id); } /// - public async ValueTask Set(Guid id, InternalVoidUser user) + public async ValueTask Set(Guid id, InternalUser user) { if (id != user.Id) throw new InvalidOperationException(); @@ -49,7 +50,7 @@ public class CacheUserStore : IUserStore } /// - public async ValueTask> ListUsers(PagedRequest request) + public async ValueTask> ListUsers(PagedRequest request) { var users = (await _cache.GetList(UserList)) .Select(a => Guid.TryParse(a, out var g) ? g : null) @@ -61,9 +62,9 @@ public class CacheUserStore : IUserStore _ => users }; - async IAsyncEnumerable EnumerateUsers(IEnumerable ids) + async IAsyncEnumerable EnumerateUsers(IEnumerable ids) { - var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get(a))); + var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get(a))); foreach (var user in usersLoaded) { if (user != default) @@ -83,17 +84,17 @@ public class CacheUserStore : IUserStore } /// - public async ValueTask UpdateProfile(PublicVoidUser newUser) + public async ValueTask UpdateProfile(PublicUser newUser) { - var oldUser = await Get(newUser.Id); + var oldUser = await Get(newUser.Id); if (oldUser == null) return; //retain flags - var isEmailVerified = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified); + var isEmailVerified = oldUser.Flags.HasFlag(UserFlags.EmailVerified); // update only a few props oldUser.Avatar = newUser.Avatar; - oldUser.Flags = newUser.Flags | (isEmailVerified ? VoidUserFlags.EmailVerified : 0); + oldUser.Flags = newUser.Flags | (isEmailVerified ? UserFlags.EmailVerified : 0); oldUser.DisplayName = newUser.DisplayName; await Set(newUser.Id, oldUser); @@ -102,7 +103,7 @@ public class CacheUserStore : IUserStore /// public async ValueTask UpdateLastLogin(Guid id, DateTime timestamp) { - var user = await Get(id); + var user = await Get(id); if (user != default) { user.LastLogin = timestamp; @@ -111,9 +112,9 @@ public class CacheUserStore : IUserStore } /// - public async ValueTask AdminUpdateUser(PrivateVoidUser user) + public async ValueTask AdminUpdateUser(PrivateUser user) { - var oldUser = await Get(user.Id); + var oldUser = await Get(user.Id); if (oldUser == null) return; oldUser.Email = user.Email; @@ -125,12 +126,12 @@ public class CacheUserStore : IUserStore /// public async ValueTask Delete(Guid id) { - var user = await Get(id); + var user = await Get(id); if (user == default) throw new InvalidOperationException(); await Delete(user); } - private async ValueTask Delete(PrivateVoidUser user) + private async ValueTask Delete(PrivateUser user) { await _cache.Delete(MapKey(user.Id)); await _cache.RemoveFromList(UserList, user.Id.ToString()); diff --git a/VoidCat/Services/Users/PostgresApiKeyStore.cs b/VoidCat/Services/Users/PostgresApiKeyStore.cs index 59ce27b..ccb029e 100644 --- a/VoidCat/Services/Users/PostgresApiKeyStore.cs +++ b/VoidCat/Services/Users/PostgresApiKeyStore.cs @@ -1,5 +1,5 @@ using Dapper; -using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; diff --git a/VoidCat/Services/Users/PostgresUserStore.cs b/VoidCat/Services/Users/PostgresUserStore.cs index 02a88cc..72e26af 100644 --- a/VoidCat/Services/Users/PostgresUserStore.cs +++ b/VoidCat/Services/Users/PostgresUserStore.cs @@ -1,5 +1,6 @@ using Dapper; using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; @@ -15,25 +16,25 @@ public class PostgresUserStore : IUserStore } /// - public async ValueTask Get(Guid id) + public async ValueTask Get(Guid id) { - return await Get(id); + return await Get(id); } /// - public async ValueTask GetPrivate(Guid id) + public async ValueTask GetPrivate(Guid id) { - return await Get(id); + return await Get(id); } /// - public async ValueTask Set(Guid id, InternalVoidUser obj) + public async ValueTask Set(Guid id, InternalUser obj) { await using var conn = await _connection.Get(); await conn.ExecuteAsync( @"insert into -""Users""(""Id"", ""Email"", ""Password"", ""Created"", ""LastLogin"", ""DisplayName"", ""Avatar"", ""Flags"") -values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :flags)", +""Users""(""Id"", ""Email"", ""Password"", ""Created"", ""LastLogin"", ""DisplayName"", ""Avatar"", ""Flags"", ""AuthType"") +values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :flags, :authType)", new { Id = id, @@ -43,7 +44,8 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla displayName = obj.DisplayName, lastLogin = obj.LastLogin.ToUniversalTime(), avatar = obj.Avatar, - flags = (int)obj.Flags + flags = (int)obj.Flags, + authType = (int)obj.AuthType }); if (obj.Roles.Any(a => a != Roles.User)) @@ -65,7 +67,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla } /// - public async ValueTask Get(Guid id) where T : VoidUser + public async ValueTask Get(Guid id) where T : User { await using var conn = await _connection.Get(); var user = await conn.QuerySingleOrDefaultAsync(@"select * from ""Users"" where ""Id"" = :id", @@ -96,12 +98,12 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla } /// - public async ValueTask> ListUsers(PagedRequest request) + public async ValueTask> ListUsers(PagedRequest request) { await using var conn = await _connection.Get(); var totalUsers = await conn.ExecuteScalarAsync(@"select count(*) from ""Users"""); - async IAsyncEnumerable Enumerate() + async IAsyncEnumerable Enumerate() { var orderBy = request.SortBy switch { @@ -125,7 +127,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla limit = request.PageSize }); - var rowParser = users.GetRowParser(); + var rowParser = users.GetRowParser(); while (await users.ReadAsync()) { yield return rowParser(users); @@ -142,12 +144,12 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla } /// - public async ValueTask UpdateProfile(PublicVoidUser newUser) + public async ValueTask UpdateProfile(PublicUser newUser) { - var oldUser = await Get(newUser.Id); + var oldUser = await Get(newUser.Id); if (oldUser == null) return; - var emailFlag = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified) ? VoidUserFlags.EmailVerified : 0; + var emailFlag = oldUser.Flags.HasFlag(UserFlags.EmailVerified) ? UserFlags.EmailVerified : 0; await using var conn = await _connection.Get(); await conn.ExecuteAsync( @"update ""Users"" set ""DisplayName"" = :displayName, ""Avatar"" = :avatar, ""Flags"" = :flags where ""Id"" = :id", @@ -169,7 +171,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla } /// - public async ValueTask AdminUpdateUser(PrivateVoidUser user) + public async ValueTask AdminUpdateUser(PrivateUser user) { await using var conn = await _connection.Get(); await conn.ExecuteAsync( diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index 8f4a5a6..bd224bc 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -1,44 +1,60 @@ using VoidCat.Model; +using VoidCat.Model.User; using VoidCat.Services.Abstractions; +using VoidCat.Services.Users.Auth; namespace VoidCat.Services.Users; -/// -public class UserManager : IUserManager +public class UserManager { private readonly IUserStore _store; private readonly IEmailVerification _emailVerification; + private readonly IUserAuthTokenStore _tokenStore; + private readonly OAuthFactory _oAuthFactory; private static bool _checkFirstRegister; - public UserManager(IUserStore store, IEmailVerification emailVerification) + public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory, + IUserAuthTokenStore tokenStore) { _store = store; _emailVerification = emailVerification; + _oAuthFactory = oAuthFactory; + _tokenStore = tokenStore; } - /// - public async ValueTask Login(string email, string password) + /// + /// Login an existing user with email/password + /// + /// + /// + /// + /// + public async ValueTask Login(string email, string password) { var userId = await _store.LookupUser(email); if (!userId.HasValue) throw new InvalidOperationException("User does not exist"); var user = await _store.GetPrivate(userId.Value); if (!(user?.CheckPassword(password) ?? false)) throw new InvalidOperationException("User does not exist"); - - user.LastLogin = DateTimeOffset.UtcNow; - await _store.UpdateLastLogin(user.Id, DateTime.UtcNow); + await HandleLogin(user); return user; } - /// - public async ValueTask Register(string email, string password) + /// + /// Register a new internal user with email/password + /// + /// + /// + /// + /// + public async ValueTask Register(string email, string password) { var existingUser = await _store.LookupUser(email); if (existingUser != Guid.Empty && existingUser != null) throw new InvalidOperationException("User already exists"); - var newUser = new InternalVoidUser + var newUser = new InternalUser { Id = Guid.NewGuid(), Email = email, @@ -47,6 +63,56 @@ public class UserManager : IUserManager LastLogin = DateTimeOffset.UtcNow }; + await SetupNewUser(newUser); + return newUser; + } + + /// + /// Start OAuth2 authorization flow + /// + /// + /// + public Uri Authorize(string provider) + { + var px = _oAuthFactory.GetProvider(provider); + return px.Authorize(); + } + + /// + /// Login or Register with OAuth2 auth code + /// + /// + /// + /// + public async ValueTask LoginOrRegister(string code, string provider) + { + var px = _oAuthFactory.GetProvider(provider); + var token = await px.GetToken(code); + + var user = await px.GetUserDetails(token); + if (user == default) + { + throw new InvalidOperationException($"Could not load user profile from provider: {provider}"); + } + + var uid = await _store.LookupUser(user.Email); + if (uid.HasValue) + { + var existingUser = await _store.GetPrivate(uid.Value); + if (existingUser?.AuthType == AuthType.OAuth2) + { + return existingUser; + } + + throw new InvalidOperationException("Auth failure, user type does not match!"); + } + + await SetupNewUser(user); + return user; + } + + private async Task SetupNewUser(InternalUser newUser) + { // automatically set first user to admin if (!_checkFirstRegister) { @@ -60,6 +126,11 @@ public class UserManager : IUserManager await _store.Set(newUser.Id, newUser); await _emailVerification.SendNewCode(newUser); - return newUser; + } + + private async Task HandleLogin(InternalUser user) + { + user.LastLogin = DateTimeOffset.UtcNow; + await _store.UpdateLastLogin(user.Id, DateTime.UtcNow); } } \ No newline at end of file diff --git a/VoidCat/Services/Users/UsersStartup.cs b/VoidCat/Services/Users/UsersStartup.cs index a4da97d..989b9a4 100644 --- a/VoidCat/Services/Users/UsersStartup.cs +++ b/VoidCat/Services/Users/UsersStartup.cs @@ -1,5 +1,6 @@ using VoidCat.Model; using VoidCat.Services.Abstractions; +using VoidCat.Services.Users.Auth; namespace VoidCat.Services.Users; @@ -7,19 +8,27 @@ public static class UsersStartup { public static void AddUserServices(this IServiceCollection services, VoidSettings settings) { - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + if (settings.HasDiscord()) + { + services.AddTransient(); + } if (settings.HasPostgres()) { services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } else { services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } -} +} \ No newline at end of file diff --git a/VoidCat/Services/VirusScanner/CacheVirusScanStore.cs b/VoidCat/Services/VirusScanner/CacheVirusScanStore.cs index cc25a59..ea47c9f 100644 --- a/VoidCat/Services/VirusScanner/CacheVirusScanStore.cs +++ b/VoidCat/Services/VirusScanner/CacheVirusScanStore.cs @@ -14,13 +14,13 @@ public class CacheVirusScanStore : BasicCacheStore, IVirusScanS public override async ValueTask Add(Guid id, VirusScanResult obj) { await base.Add(id, obj); - await Cache.AddToList(MapFilesKey(id), obj.Id.ToString()); + await _cache.AddToList(MapFilesKey(id), obj.Id.ToString()); } /// public async ValueTask GetByFile(Guid id) { - var scans = await Cache.GetList(MapFilesKey(id)); + var scans = await _cache.GetList(MapFilesKey(id)); if (scans.Length > 0) { return await Get(Guid.Parse(scans.First())); diff --git a/VoidCat/Services/VirusScanner/PostgresVirusScanStore.cs b/VoidCat/Services/VirusScanner/PostgresVirusScanStore.cs index 2c0b7b9..9cb8ff7 100644 --- a/VoidCat/Services/VirusScanner/PostgresVirusScanStore.cs +++ b/VoidCat/Services/VirusScanner/PostgresVirusScanStore.cs @@ -30,14 +30,6 @@ public class PostgresVirusScanStore : IVirusScanStore @"select * from ""VirusScanResult"" where ""File"" = :file", new {file = id}); } - /// - public async ValueTask> Get(Guid[] ids) - { - await using var conn = await _connection.Get(); - return (await conn.QueryAsync( - @"select * from ""VirusScanResult"" where ""Id"" in :ids", new {ids = ids.ToArray()})).ToList(); - } - /// public async ValueTask Add(Guid id, VirusScanResult obj) { diff --git a/VoidCat/spa/src/Login.js b/VoidCat/spa/src/Login.js index 95a6ff7..8036225 100644 --- a/VoidCat/spa/src/Login.js +++ b/VoidCat/spa/src/Login.js @@ -42,6 +42,8 @@ export function Login() { {captchaKey ? : null} login(Api.login)}>Login login(Api.register)}>Register +
+ window.location.href = `/auth/discord`}>Login with Discord {error ?
{error}
: null} ); diff --git a/VoidCat/spa/src/LoginState.js b/VoidCat/spa/src/LoginState.js index c87118b..e351763 100644 --- a/VoidCat/spa/src/LoginState.js +++ b/VoidCat/spa/src/LoginState.js @@ -4,7 +4,8 @@ const LocalStorageKey = "token"; export const LoginState = createSlice({ name: "Login", initialState: { - jwt: window.localStorage.getItem(LocalStorageKey), + jwt: window.localStorage.getItem(LocalStorageKey) || (window.location.pathname === "/login" && window.location.hash.length > 1 + ? window.location.hash.substring(1) : null), profile: null }, reducers: { diff --git a/VoidCat/spa/src/UserLogin.js b/VoidCat/spa/src/UserLogin.js index e80d2c3..c4d5803 100644 --- a/VoidCat/spa/src/UserLogin.js +++ b/VoidCat/spa/src/UserLogin.js @@ -6,13 +6,13 @@ import {useEffect} from "react"; export function UserLogin() { const auth = useSelector((state) => state.login.jwt); const navigate = useNavigate(); - + useEffect(() => { - if(auth){ + if (auth) { navigate("/"); } }, [auth]); - + return (