OAuth2 (Discord)

This commit is contained in:
Kieran 2022-09-08 10:41:31 +01:00
parent 150579c509
commit 3f37c10ebc
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
46 changed files with 776 additions and 204 deletions

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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!;

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace VoidCat.Model;
namespace VoidCat.Model.User;
public sealed class ApiKey
{

View 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
}

View 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!;
}

View 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; }
}

View File

@ -0,0 +1,6 @@
namespace VoidCat.Model.User;
/// <inheritdoc />
public sealed class PublicUser : User
{
}

View File

@ -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
}

View 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; }
}

View 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
}

View File

@ -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

View 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; }
}
}

View File

@ -1,4 +1,4 @@
using VoidCat.Model;
using VoidCat.Model.User;
namespace VoidCat.Services.Abstractions;

View File

@ -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>

View File

@ -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);
}

View 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);
}

View File

@ -0,0 +1,10 @@
using VoidCat.Model.User;
namespace VoidCat.Services.Abstractions;
/// <summary>
/// User access token store
/// </summary>
public interface IUserAuthTokenStore : IBasicStore<UserAuthToken>
{
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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());

View File

@ -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>

View File

@ -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
};
}

View File

@ -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()

View 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");
}
}

View File

@ -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; }

View File

@ -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
};
}

View 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}";
}

View 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; }
}

View 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; }
}

View 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;
}
}

View 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});
}
}

View File

@ -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;

View File

@ -1,4 +1,4 @@
using VoidCat.Model;
using VoidCat.Model.User;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;

View File

@ -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());

View File

@ -1,5 +1,5 @@
using Dapper;
using VoidCat.Model;
using VoidCat.Model.User;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;

View File

@ -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(

View File

@ -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);
}
}

View File

@ -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>();
}
}
}
}

View File

@ -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()));

View File

@ -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)
{

View File

@ -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>
);

View File

@ -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: {

View File

@ -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/>