mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-03-27 00:31:44 +01:00
OAuth2 (Discord)
This commit is contained in:
parent
150579c509
commit
3f37c10ebc
@ -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
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
[Route("update-user")]
|
||||
public async Task<IActionResult> UpdateUser([FromBody] PrivateVoidUser user)
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -96,6 +99,35 @@ public class AuthController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth2 authorize flow
|
||||
/// </summary>
|
||||
/// <param name="provider">OAuth provider</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
[Route("{provider}")]
|
||||
public IActionResult Authorize([FromRoute] string provider)
|
||||
{
|
||||
return Redirect(_manager.Authorize(provider).ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authorize user from OAuth2 code grant
|
||||
/// </summary>
|
||||
/// <param name="code">Code used to generate access token</param>
|
||||
/// <param name="provider">OAuth provider</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
[Route("{provider}/token")]
|
||||
public async Task<IActionResult> 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)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List api keys for the user
|
||||
/// </summary>
|
||||
@ -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);
|
||||
}
|
||||
}
|
@ -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<InternalVoidUser>(uid.Value);
|
||||
var user = await _userStore.Get<InternalUser>(uid.Value);
|
||||
if (user?.Storage != default)
|
||||
{
|
||||
store = user.Storage!;
|
||||
|
@ -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<PrivateVoidUser>(requestedId);
|
||||
var pUser = await _store.Get<PrivateUser>(requestedId);
|
||||
if (pUser == default) return NotFound();
|
||||
|
||||
return Json(pUser);
|
||||
}
|
||||
|
||||
var user = await _store.Get<PublicVoidUser>(requestedId);
|
||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return NotFound();
|
||||
var user = await _store.Get<PublicUser>(requestedId);
|
||||
if (!(user?.Flags.HasFlag(UserFlags.PublicProfile) ?? false)) return NotFound();
|
||||
|
||||
return Json(user);
|
||||
}
|
||||
@ -62,12 +63,12 @@ public class UserController : Controller
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateUser([FromRoute] string id, [FromBody] PublicVoidUser user)
|
||||
public async Task<IActionResult> 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<InternalVoidUser?> GetAuthorizedUser(string id)
|
||||
private async Task<InternalUser?> GetAuthorizedUser(string id)
|
||||
{
|
||||
var loggedUser = HttpContext.GetUserId();
|
||||
var gid = id.FromBase58Guid();
|
||||
var user = await _store.Get<InternalVoidUser>(gid);
|
||||
var user = await _store.Get<InternalUser>(gid);
|
||||
return user?.Id != loggedUser ? default : user;
|
||||
}
|
||||
|
||||
private async Task<InternalVoidUser?> GetRequestedUser(string id)
|
||||
private async Task<InternalUser?> GetRequestedUser(string id)
|
||||
{
|
||||
var gid = id.FromBase58Guid();
|
||||
return await _store.Get<InternalVoidUser>(gid);
|
||||
return await _store.Get<InternalUser>(gid);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
/// <summary>
|
||||
/// Validate password matches hashed password
|
||||
/// </summary>
|
||||
/// <param name="vu"></param>
|
||||
/// <param name="password"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
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;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace VoidCat.Model;
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
public sealed class ApiKey
|
||||
{
|
27
VoidCat/Model/User/AuthType.cs
Normal file
27
VoidCat/Model/User/AuthType.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// User account authentication type
|
||||
/// </summary>
|
||||
public enum AuthType
|
||||
{
|
||||
/// <summary>
|
||||
/// Encrypted password
|
||||
/// </summary>
|
||||
Internal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// PGP challenge
|
||||
/// </summary>
|
||||
PGP = 1,
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 token
|
||||
/// </summary>
|
||||
OAuth2 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Lightning node challenge
|
||||
/// </summary>
|
||||
Lightning = 3
|
||||
}
|
12
VoidCat/Model/User/InternalUser.cs
Normal file
12
VoidCat/Model/User/InternalUser.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// Internal user object used by the system
|
||||
/// </summary>
|
||||
public sealed class InternalUser : PrivateUser
|
||||
{
|
||||
/// <summary>
|
||||
/// A password hash for the user in the format <see cref="Extensions.HashPassword"/>
|
||||
/// </summary>
|
||||
public string Password { get; init; } = null!;
|
||||
}
|
17
VoidCat/Model/User/PrivateUser.cs
Normal file
17
VoidCat/Model/User/PrivateUser.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// A user object which includes the Email
|
||||
/// </summary>
|
||||
public class PrivateUser : User
|
||||
{
|
||||
/// <summary>
|
||||
/// Users email address
|
||||
/// </summary>
|
||||
public string Email { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Users storage system for new uploads
|
||||
/// </summary>
|
||||
public string? Storage { get; set; }
|
||||
}
|
6
VoidCat/Model/User/PublicUser.cs
Normal file
6
VoidCat/Model/User/PublicUser.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class PublicUser : User
|
||||
{
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
namespace VoidCat.Model;
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// The base user object for the system
|
||||
/// </summary>
|
||||
public abstract class VoidUser
|
||||
public abstract class User
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique Id of the user
|
||||
@ -43,13 +41,18 @@ public abstract class VoidUser
|
||||
/// <summary>
|
||||
/// Profile flags
|
||||
/// </summary>
|
||||
public VoidUserFlags Flags { get; set; } = VoidUserFlags.PublicProfile;
|
||||
public UserFlags Flags { get; set; } = UserFlags.PublicProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Account authentication type
|
||||
/// </summary>
|
||||
public AuthType AuthType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Public object for this user
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public PublicVoidUser ToPublic()
|
||||
public PublicUser ToPublic()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
@ -61,44 +64,4 @@ public abstract class VoidUser
|
||||
Flags = Flags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal user object used by the system
|
||||
/// </summary>
|
||||
public sealed class InternalVoidUser : PrivateVoidUser
|
||||
{
|
||||
/// <summary>
|
||||
/// A password hash for the user in the format <see cref="Extensions.HashPassword"/>
|
||||
/// </summary>
|
||||
public string Password { get; init; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A user object which includes the Email
|
||||
/// </summary>
|
||||
public class PrivateVoidUser : VoidUser
|
||||
{
|
||||
/// <summary>
|
||||
/// Users email address
|
||||
/// </summary>
|
||||
public string Email { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Users storage system for new uploads
|
||||
/// </summary>
|
||||
public string? Storage { get; set; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class PublicVoidUser : VoidUser
|
||||
{
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum VoidUserFlags
|
||||
{
|
||||
PublicProfile = 1,
|
||||
PublicUploads = 2,
|
||||
EmailVerified = 4
|
||||
}
|
23
VoidCat/Model/User/UserAuthToken.cs
Normal file
23
VoidCat/Model/User/UserAuthToken.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 access token
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
23
VoidCat/Model/User/UserFlags.cs
Normal file
23
VoidCat/Model/User/UserFlags.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace VoidCat.Model.User;
|
||||
|
||||
/// <summary>
|
||||
/// Account status flags
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum UserFlags
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile is public
|
||||
/// </summary>
|
||||
PublicProfile = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Uploads list is public
|
||||
/// </summary>
|
||||
PublicUploads = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Account has email verified
|
||||
/// </summary>
|
||||
EmailVerified = 4
|
||||
}
|
@ -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
|
||||
/// <summary>
|
||||
/// User profile that uploaded the file
|
||||
/// </summary>
|
||||
public PublicVoidUser? Uploader { get; init; }
|
||||
public PublicUser? Uploader { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Traffic stats for this file
|
||||
|
@ -7,6 +7,11 @@ namespace VoidCat.Model
|
||||
/// </summary>
|
||||
public class VoidSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Base site url, used for redirect urls
|
||||
/// </summary>
|
||||
public Uri SiteUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Data directory to store files in
|
||||
/// </summary>
|
||||
@ -15,7 +20,7 @@ namespace VoidCat.Model
|
||||
/// <summary>
|
||||
/// Size in bytes to split uploads into chunks
|
||||
/// </summary>
|
||||
public ulong? UploadSegmentSize { get; init; } = null;
|
||||
public ulong? UploadSegmentSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public string DefaultFileStore { get; init; } = "local-disk";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Plausible Analytics endpoint url
|
||||
/// </summary>
|
||||
public PlausibleSettings? PlausibleAnalytics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discord application settings
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
|
@ -12,14 +12,7 @@ public interface IBasicStore<T>
|
||||
/// <param name="id"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask<T?> Get(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Get multiple items from the store
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask<IReadOnlyList<T>> Get(Guid[] ids);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Add an item to the store
|
||||
/// </summary>
|
||||
|
@ -1,10 +1,11 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
public interface IEmailVerification
|
||||
{
|
||||
ValueTask<EmailVerificationCode> SendNewCode(PrivateVoidUser user);
|
||||
ValueTask<EmailVerificationCode> SendNewCode(PrivateUser user);
|
||||
|
||||
ValueTask<bool> VerifyCode(PrivateVoidUser user, Guid code);
|
||||
ValueTask<bool> VerifyCode(PrivateUser user, Guid code);
|
||||
}
|
34
VoidCat/Services/Abstractions/IOAuthProvider.cs
Normal file
34
VoidCat/Services/Abstractions/IOAuthProvider.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 code grant provider
|
||||
/// </summary>
|
||||
public interface IOAuthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of this provider
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate authorization code grant uri
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Uri Authorize();
|
||||
|
||||
/// <summary>
|
||||
/// Get access token from auth code
|
||||
/// </summary>
|
||||
/// <param name="code"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask<UserAuthToken> GetToken(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Get a user object which represents this external account authorization
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask<InternalUser?> GetUserDetails(UserAuthToken token);
|
||||
}
|
10
VoidCat/Services/Abstractions/IUserAuthTokenStore.cs
Normal file
10
VoidCat/Services/Abstractions/IUserAuthTokenStore.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// User access token store
|
||||
/// </summary>
|
||||
public interface IUserAuthTokenStore : IBasicStore<UserAuthToken>
|
||||
{
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
using VoidCat.Model;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
public interface IUserManager
|
||||
{
|
||||
ValueTask<InternalVoidUser> Login(string username, string password);
|
||||
ValueTask<InternalVoidUser> Register(string username, string password);
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// User store
|
||||
/// </summary>
|
||||
public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
|
||||
public interface IUserStore : IPublicPrivateStore<User, InternalUser>
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a single user
|
||||
@ -13,7 +14,7 @@ public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
|
||||
/// <param name="id"></param>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <returns></returns>
|
||||
ValueTask<T?> Get<T>(Guid id) where T : VoidUser;
|
||||
ValueTask<T?> Get<T>(Guid id) where T : User;
|
||||
|
||||
/// <summary>
|
||||
/// Lookup a user by their email address
|
||||
@ -27,14 +28,14 @@ public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask<PagedResult<PrivateVoidUser>> ListUsers(PagedRequest request);
|
||||
ValueTask<PagedResult<PrivateUser>> ListUsers(PagedRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Update a users profile
|
||||
/// </summary>
|
||||
/// <param name="newUser"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask UpdateProfile(PublicVoidUser newUser);
|
||||
ValueTask UpdateProfile(PublicUser newUser);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the last login timestamp for the user
|
||||
@ -49,5 +50,5 @@ public interface IUserStore : IPublicPrivateStore<VoidUser, InternalVoidUser>
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask AdminUpdateUser(PrivateVoidUser user);
|
||||
ValueTask AdminUpdateUser(PrivateUser user);
|
||||
}
|
@ -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());
|
||||
|
@ -5,45 +5,29 @@ namespace VoidCat.Services;
|
||||
/// <inheritdoc />
|
||||
public abstract class BasicCacheStore<TStore> : IBasicStore<TStore>
|
||||
{
|
||||
protected readonly ICache Cache;
|
||||
protected readonly ICache _cache;
|
||||
|
||||
protected BasicCacheStore(ICache cache)
|
||||
{
|
||||
Cache = cache;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask<TStore?> Get(Guid id)
|
||||
{
|
||||
return Cache.Get<TStore>(MapKey(id));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask<IReadOnlyList<TStore>> Get(Guid[] ids)
|
||||
{
|
||||
var ret = new List<TStore>();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var r = await Cache.Get<TStore>(MapKey(id));
|
||||
if (r != null)
|
||||
{
|
||||
ret.Add(r);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
return _cache.Get<TStore>(MapKey(id));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask Add(Guid id, TStore obj)
|
||||
{
|
||||
return Cache.Set(MapKey(id), obj);
|
||||
return _cache.Set(MapKey(id), obj);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask Delete(Guid id)
|
||||
{
|
||||
return Cache.Delete(MapKey(id));
|
||||
return _cache.Delete(MapKey(id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -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<PublicVoidUser>(uploader.Result.Value) : null;
|
||||
var user = uploader.Result.HasValue ? await _userStore.Get<PublicUser>(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
|
||||
};
|
||||
}
|
||||
|
@ -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()
|
||||
|
44
VoidCat/Services/Migrations/Database/05-AccountTypes.cs
Normal file
44
VoidCat/Services/Migrations/Database/05-AccountTypes.cs
Normal file
@ -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");
|
||||
}
|
||||
}
|
@ -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<PrivateUser>(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; }
|
||||
|
@ -14,11 +14,11 @@ public class CachePaymentStore : BasicCacheStore<PaymentConfig>, IPaymentStore
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<PaymentConfig?> Get(Guid id)
|
||||
{
|
||||
var cfg = await Cache.Get<NoPaymentConfig>(MapKey(id));
|
||||
var cfg = await _cache.Get<NoPaymentConfig>(MapKey(id));
|
||||
return cfg?.Service switch
|
||||
{
|
||||
PaymentServices.None => cfg,
|
||||
PaymentServices.Strike => await Cache.Get<StrikePaymentConfig>(MapKey(id)),
|
||||
PaymentServices.Strike => await _cache.Get<StrikePaymentConfig>(MapKey(id)),
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
15
VoidCat/Services/Users/Auth/CacheUserAuthTokenStore.cs
Normal file
15
VoidCat/Services/Users/Auth/CacheUserAuthTokenStore.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users.Auth;
|
||||
|
||||
/// <inheritdoc cref="IUserAuthTokenStore"/>
|
||||
public class CacheUserAuthTokenStore : BasicCacheStore<UserAuthToken>, IUserAuthTokenStore
|
||||
{
|
||||
public CacheUserAuthTokenStore(ICache cache) : base(cache)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MapKey(Guid id) => $"auth-token:{id}";
|
||||
}
|
118
VoidCat/Services/Users/Auth/DiscordOAuthProvider.cs
Normal file
118
VoidCat/Services/Users/Auth/DiscordOAuthProvider.cs
Normal file
@ -0,0 +1,118 @@
|
||||
using Newtonsoft.Json;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
|
||||
namespace VoidCat.Services.Users.Auth;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class DiscordOAuthProvider : GenericOAuth2Service<DiscordAccessToken>
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Id => "discord";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<InternalUser?> 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<DiscordUser>(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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Dictionary<string, string> 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<string, string> 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()}
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Uri AuthorizeEndpoint => new("https://discord.com/oauth2/authorize");
|
||||
|
||||
/// <inheritdoc />
|
||||
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; }
|
||||
}
|
78
VoidCat/Services/Users/Auth/GenericOAuth2Service.cs
Normal file
78
VoidCat/Services/Users/Auth/GenericOAuth2Service.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using Newtonsoft.Json;
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Generic base class for OAuth2 code grant flow
|
||||
/// </summary>
|
||||
public abstract class GenericOAuth2Service<TDto> : IOAuthProvider
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
protected GenericOAuth2Service(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string Id { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Uri Authorize()
|
||||
{
|
||||
var ub = new UriBuilder(AuthorizeEndpoint)
|
||||
{
|
||||
Query = string.Join("&", BuildAuthorizeQuery().Select(a => $"{a.Key}={Uri.EscapeDataString(a.Value)}"))
|
||||
};
|
||||
|
||||
return ub.Uri;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<UserAuthToken> 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<TDto>(json);
|
||||
return TransformDto(dto!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract ValueTask<InternalUser?> GetUserDetails(UserAuthToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Build query args for authorize
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected abstract Dictionary<string, string> BuildAuthorizeQuery();
|
||||
|
||||
/// <summary>
|
||||
/// Build query args for token generation
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected abstract Dictionary<string, string> BuildTokenQuery(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Transform DTO to <see cref="UserAuthToken"/>
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract UserAuthToken TransformDto(TDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Authorize url for this service
|
||||
/// </summary>
|
||||
protected abstract Uri AuthorizeEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate token url for this service
|
||||
/// </summary>
|
||||
protected abstract Uri TokenEndpoint { get; }
|
||||
}
|
33
VoidCat/Services/Users/Auth/OAuthFactory.cs
Normal file
33
VoidCat/Services/Users/Auth/OAuthFactory.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Factory class to access specific OAuth providers
|
||||
/// </summary>
|
||||
public sealed class OAuthFactory
|
||||
{
|
||||
private readonly IEnumerable<IOAuthProvider> _providers;
|
||||
|
||||
public OAuthFactory(IEnumerable<IOAuthProvider> providers)
|
||||
{
|
||||
_providers = providers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an OAuth provider by id
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
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;
|
||||
}
|
||||
}
|
56
VoidCat/Services/Users/Auth/PostgresUserAuthTokenStore.cs
Normal file
56
VoidCat/Services/Users/Auth/PostgresUserAuthTokenStore.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using Dapper;
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users.Auth;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class PostgresUserAuthTokenStore : IUserAuthTokenStore
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connection;
|
||||
|
||||
public PostgresUserAuthTokenStore(PostgresConnectionFactory connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<UserAuthToken?> Get(Guid id)
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
return await conn.QuerySingleOrDefaultAsync<UserAuthToken>(
|
||||
@"select * from ""UsersAuthToken"" where ""User"" = :id", new {id});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask Delete(Guid id)
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
await conn.ExecuteAsync(@"delete from ""UsersAuthToken"" where ""Id"" = :id", new {id});
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EmailVerificationCode> SendNewCode(PrivateVoidUser user)
|
||||
public async ValueTask<EmailVerificationCode> 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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> VerifyCode(PrivateVoidUser user, Guid code)
|
||||
public async ValueTask<bool> VerifyCode(PrivateUser user, Guid code)
|
||||
{
|
||||
var token = await GetToken(user.Id, code);
|
||||
if (token == default) return false;
|
||||
|
@ -1,4 +1,4 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users;
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<T?> Get<T>(Guid id) where T : VoidUser
|
||||
public ValueTask<T?> Get<T>(Guid id) where T : User
|
||||
{
|
||||
return _cache.Get<T>(MapKey(id));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<VoidUser?> Get(Guid id)
|
||||
public ValueTask<User?> Get(Guid id)
|
||||
{
|
||||
return Get<VoidUser>(id);
|
||||
return Get<User>(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<InternalVoidUser?> GetPrivate(Guid id)
|
||||
public ValueTask<InternalUser?> GetPrivate(Guid id)
|
||||
{
|
||||
return Get<InternalVoidUser>(id);
|
||||
return Get<InternalUser>(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<PagedResult<PrivateVoidUser>> ListUsers(PagedRequest request)
|
||||
public async ValueTask<PagedResult<PrivateUser>> ListUsers(PagedRequest request)
|
||||
{
|
||||
var users = (await _cache.GetList(UserList))
|
||||
.Select<string, Guid?>(a => Guid.TryParse(a, out var g) ? g : null)
|
||||
@ -61,9 +62,9 @@ public class CacheUserStore : IUserStore
|
||||
_ => users
|
||||
};
|
||||
|
||||
async IAsyncEnumerable<PrivateVoidUser> EnumerateUsers(IEnumerable<Guid> ids)
|
||||
async IAsyncEnumerable<PrivateUser> EnumerateUsers(IEnumerable<Guid> ids)
|
||||
{
|
||||
var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get<PrivateVoidUser>(a)));
|
||||
var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get<PrivateUser>(a)));
|
||||
foreach (var user in usersLoaded)
|
||||
{
|
||||
if (user != default)
|
||||
@ -83,17 +84,17 @@ public class CacheUserStore : IUserStore
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask UpdateProfile(PublicVoidUser newUser)
|
||||
public async ValueTask UpdateProfile(PublicUser newUser)
|
||||
{
|
||||
var oldUser = await Get<InternalVoidUser>(newUser.Id);
|
||||
var oldUser = await Get<InternalUser>(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
|
||||
/// <inheritdoc />
|
||||
public async ValueTask UpdateLastLogin(Guid id, DateTime timestamp)
|
||||
{
|
||||
var user = await Get<InternalVoidUser>(id);
|
||||
var user = await Get<InternalUser>(id);
|
||||
if (user != default)
|
||||
{
|
||||
user.LastLogin = timestamp;
|
||||
@ -111,9 +112,9 @@ public class CacheUserStore : IUserStore
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask AdminUpdateUser(PrivateVoidUser user)
|
||||
public async ValueTask AdminUpdateUser(PrivateUser user)
|
||||
{
|
||||
var oldUser = await Get<InternalVoidUser>(user.Id);
|
||||
var oldUser = await Get<InternalUser>(user.Id);
|
||||
if (oldUser == null) return;
|
||||
|
||||
oldUser.Email = user.Email;
|
||||
@ -125,12 +126,12 @@ public class CacheUserStore : IUserStore
|
||||
/// <inheritdoc />
|
||||
public async ValueTask Delete(Guid id)
|
||||
{
|
||||
var user = await Get<InternalVoidUser>(id);
|
||||
var user = await Get<InternalUser>(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());
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Dapper;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services.Users;
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<VoidUser?> Get(Guid id)
|
||||
public async ValueTask<User?> Get(Guid id)
|
||||
{
|
||||
return await Get<PublicVoidUser>(id);
|
||||
return await Get<PublicUser>(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<InternalVoidUser?> GetPrivate(Guid id)
|
||||
public async ValueTask<InternalUser?> GetPrivate(Guid id)
|
||||
{
|
||||
return await Get<InternalVoidUser>(id);
|
||||
return await Get<InternalUser>(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<T?> Get<T>(Guid id) where T : VoidUser
|
||||
public async ValueTask<T?> Get<T>(Guid id) where T : User
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
var user = await conn.QuerySingleOrDefaultAsync<T?>(@"select * from ""Users"" where ""Id"" = :id",
|
||||
@ -96,12 +98,12 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<PagedResult<PrivateVoidUser>> ListUsers(PagedRequest request)
|
||||
public async ValueTask<PagedResult<PrivateUser>> ListUsers(PagedRequest request)
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
var totalUsers = await conn.ExecuteScalarAsync<int>(@"select count(*) from ""Users""");
|
||||
|
||||
async IAsyncEnumerable<PrivateVoidUser> Enumerate()
|
||||
async IAsyncEnumerable<PrivateUser> 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<PrivateVoidUser>();
|
||||
var rowParser = users.GetRowParser<PrivateUser>();
|
||||
while (await users.ReadAsync())
|
||||
{
|
||||
yield return rowParser(users);
|
||||
@ -142,12 +144,12 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask UpdateProfile(PublicVoidUser newUser)
|
||||
public async ValueTask UpdateProfile(PublicUser newUser)
|
||||
{
|
||||
var oldUser = await Get<InternalVoidUser>(newUser.Id);
|
||||
var oldUser = await Get<InternalUser>(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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask AdminUpdateUser(PrivateVoidUser user)
|
||||
public async ValueTask AdminUpdateUser(PrivateUser user)
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
await conn.ExecuteAsync(
|
||||
|
@ -1,44 +1,60 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.User;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Users.Auth;
|
||||
|
||||
namespace VoidCat.Services.Users;
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<InternalVoidUser> Login(string email, string password)
|
||||
/// <summary>
|
||||
/// Login an existing user with email/password
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <param name="password"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public async ValueTask<InternalUser> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<InternalVoidUser> Register(string email, string password)
|
||||
/// <summary>
|
||||
/// Register a new internal user with email/password
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <param name="password"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public async ValueTask<InternalUser> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth2 authorization flow
|
||||
/// </summary>
|
||||
/// <param name="provider"></param>
|
||||
/// <returns></returns>
|
||||
public Uri Authorize(string provider)
|
||||
{
|
||||
var px = _oAuthFactory.GetProvider(provider);
|
||||
return px.Authorize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Login or Register with OAuth2 auth code
|
||||
/// </summary>
|
||||
/// <param name="code"></param>
|
||||
/// <param name="provider"></param>
|
||||
/// <returns></returns>
|
||||
public async ValueTask<InternalUser> 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);
|
||||
}
|
||||
}
|
@ -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<IUserManager, UserManager>();
|
||||
services.AddTransient<UserManager>();
|
||||
services.AddTransient<OAuthFactory>();
|
||||
|
||||
if (settings.HasDiscord())
|
||||
{
|
||||
services.AddTransient<IOAuthProvider, DiscordOAuthProvider>();
|
||||
}
|
||||
|
||||
if (settings.HasPostgres())
|
||||
{
|
||||
services.AddTransient<IUserStore, PostgresUserStore>();
|
||||
services.AddTransient<IEmailVerification, PostgresEmailVerification>();
|
||||
services.AddTransient<IApiKeyStore, PostgresApiKeyStore>();
|
||||
services.AddTransient<IUserAuthTokenStore, PostgresUserAuthTokenStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddTransient<IUserStore, CacheUserStore>();
|
||||
services.AddTransient<IEmailVerification, CacheEmailVerification>();
|
||||
services.AddTransient<IApiKeyStore, CacheApiKeyStore>();
|
||||
services.AddTransient<IUserAuthTokenStore, CacheUserAuthTokenStore>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -14,13 +14,13 @@ public class CacheVirusScanStore : BasicCacheStore<VirusScanResult>, 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());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<VirusScanResult?> 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()));
|
||||
|
@ -30,14 +30,6 @@ public class PostgresVirusScanStore : IVirusScanStore
|
||||
@"select * from ""VirusScanResult"" where ""File"" = :file", new {file = id});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<VirusScanResult>> Get(Guid[] ids)
|
||||
{
|
||||
await using var conn = await _connection.Get();
|
||||
return (await conn.QueryAsync<VirusScanResult>(
|
||||
@"select * from ""VirusScanResult"" where ""Id"" in :ids", new {ids = ids.ToArray()})).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask Add(Guid id, VirusScanResult obj)
|
||||
{
|
||||
|
@ -42,6 +42,8 @@ export function Login() {
|
||||
{captchaKey ? <HCaptcha sitekey={captchaKey} onVerify={setCaptchaResponse}/> : null}
|
||||
<VoidButton onClick={() => login(Api.login)}>Login</VoidButton>
|
||||
<VoidButton onClick={() => login(Api.register)}>Register</VoidButton>
|
||||
<br/>
|
||||
<VoidButton onClick={() => window.location.href = `/auth/discord`}>Login with Discord</VoidButton>
|
||||
{error ? <div className="error-msg">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -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: {
|
||||
|
@ -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 (
|
||||
<div className="page">
|
||||
<Login/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user