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