Add user accounts base

This commit is contained in:
Kieran 2022-02-21 22:35:06 +00:00
parent 8629954ffe
commit 3bffcdeb13
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
33 changed files with 574 additions and 166 deletions

View File

@ -1,10 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using VoidCat.Model;
namespace VoidCat.Controllers.Admin;
[Route("admin")]
[Authorize(Policy = "Admin")]
[Authorize(Policy = Policies.RequireAdmin)]
public class AdminController : Controller
{

View File

@ -0,0 +1,77 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Controllers;
[Route("auth")]
public class AuthController : Controller
{
private readonly IUserManager _manager;
private readonly VoidSettings _settings;
public AuthController(IUserManager userStore, VoidSettings settings)
{
_manager = userStore;
_settings = settings;
}
[HttpPost]
[Route("login")]
public async Task<LoginResponse> Login([FromBody] LoginRequest req)
{
try
{
var user = await _manager.Login(req.Username, req.Password);
var token = CreateToken(user);
var tokenWriter = new JwtSecurityTokenHandler();
return new(tokenWriter.WriteToken(token), null);
}
catch (Exception ex)
{
return new(null, ex.Message);
}
}
[HttpPost]
[Route("register")]
public async Task<LoginResponse> Register([FromBody] LoginRequest req)
{
try
{
var newUser = await _manager.Register(req.Username, req.Password);
var token = CreateToken(newUser);
var tokenWriter = new JwtSecurityTokenHandler();
return new(tokenWriter.WriteToken(token), null);
}
catch (Exception ex)
{
return new(null, ex.Message);
}
}
private JwtSecurityToken CreateToken(VoidUser user)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new Claim[]
{
new(ClaimTypes.Sid, user.Id.ToString()),
new(ClaimTypes.Expiration, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new(ClaimTypes.AuthorizationDecision, string.Join(",", user.Roles))
};
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, expires: DateTime.UtcNow.AddHours(6),
signingCredentials: credentials);
}
public record LoginRequest(string Username, string Password);
public record LoginResponse(string? Jwt, string? Error = null);
}

View File

@ -0,0 +1,3 @@
namespace VoidCat.Model;
public sealed record Bandwidth(ulong Ingress, ulong Egress);

View File

@ -0,0 +1,7 @@
using VoidCat.Services.Abstractions;
namespace VoidCat.Model;
public sealed record EgressRequest(Guid Id, IEnumerable<RangeRequest> Ranges)
{
}

View File

@ -1,3 +1,6 @@
using System.Security.Cryptography;
using System.Text;
namespace VoidCat.Model;
public static class Extensions
@ -18,6 +21,135 @@ public static class Extensions
{
var h = headers
.FirstOrDefault(a => a.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase));
return !string.IsNullOrEmpty(h.Value.ToString()) ? h.Value.ToString() : default;
}
}
public static string ToHex(this byte[] data)
{
return BitConverter.ToString(data).Replace("-", string.Empty).ToLower();
}
private static int HexToInt(char c)
{
switch (c)
{
case '0':
return 0;
case '1':
return 1;
case '2':
return 2;
case '3':
return 3;
case '4':
return 4;
case '5':
return 5;
case '6':
return 6;
case '7':
return 7;
case '8':
return 8;
case '9':
return 9;
case 'a':
case 'A':
return 10;
case 'b':
case 'B':
return 11;
case 'c':
case 'C':
return 12;
case 'd':
case 'D':
return 13;
case 'e':
case 'E':
return 14;
case 'f':
case 'F':
return 15;
default:
throw new FormatException("Unrecognized hex char " + c);
}
}
private static readonly byte[,] ByteLookup = new byte[,]
{
// low nibble
{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
},
// high nibble
{
0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70,
0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0
}
};
private static byte[] FromHex(this string input)
{
var result = new byte[(input.Length + 1) >> 1];
var lastCell = result.Length - 1;
var lastChar = input.Length - 1;
for (var i = 0; i < input.Length; i++)
{
result[lastCell - (i >> 1)] |= ByteLookup[i & 1, HexToInt(input[lastChar - i])];
}
return result;
}
public static string HashPassword(this string password)
{
return password.HashPassword("pbkdf2");
}
public static string HashPassword(this string password, string algo, string? saltHex = null)
{
var bytes = Encoding.UTF8.GetBytes(password);
switch (algo)
{
case "sha256":
{
var hash = SHA256.Create().ComputeHash(bytes);
return $"sha256:${hash.ToHex()}";
}
case "sha512":
{
var hash = SHA512.Create().ComputeHash(bytes);
return $"sha512:${hash.ToHex()}";
}
case "pbkdf2":
{
const int saltSize = 32;
const int iterations = 310_000;
var salt = new byte[saltSize];
if (saltHex == default)
{
RandomNumberGenerator.Fill(salt);
}
else
{
salt = saltHex.FromHex();
}
var pbkdf2 = new Rfc2898DeriveBytes(bytes, salt, iterations);
return $"pbkdf2:{salt.ToHex()}:${pbkdf2.GetBytes(salt.Length).ToHex()}";
}
}
throw new ArgumentException("Unknown algo", nameof(algo));
}
public static bool CheckPassword(this VoidUser vu, string password)
{
var hashParts = vu.PasswordHash.Split(":");
return vu.PasswordHash == password.HashPassword(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null);
}
}

View File

@ -0,0 +1,9 @@
namespace VoidCat.Model;
public sealed record IngressPayload(Stream InStream, VoidFileMeta Meta, string Hash)
{
public Guid? Id { get; init; }
public Guid? EditSecret { get; init; }
public bool IsAppend => Id.HasValue && EditSecret.HasValue;
}

12
VoidCat/Model/Roles.cs Normal file
View File

@ -0,0 +1,12 @@
namespace VoidCat.Model;
public static class Roles
{
public const string User = "User";
public const string Admin = "Admin";
}
public static class Policies
{
public const string RequireAdmin = "RequireAdmin";
}

View File

@ -0,0 +1,6 @@
namespace VoidCat.Model;
public sealed record VoidUser(Guid Id, string Email, string PasswordHash)
{
public IEnumerable<string> Roles { get; init; } = Enumerable.Empty<string>();
}

View File

@ -11,6 +11,8 @@ using VoidCat.Services.InMemory;
using VoidCat.Services.Migrations;
using VoidCat.Services.Paywall;
using VoidCat.Services.Redis;
using VoidCat.Services.Stats;
using VoidCat.Services.Users;
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
@ -38,6 +40,7 @@ services.AddControllers().AddNewtonsoftJson((opt) =>
opt.SerializerSettings.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor;
opt.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Ignore;
});
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
@ -52,6 +55,14 @@ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
};
});
services.AddAuthorization((opt) =>
{
opt.AddPolicy(Policies.RequireAdmin, (auth) =>
{
auth.RequireRole(Roles.Admin);
});
});
// void.cat services
//
services.AddVoidMigrations();
@ -67,20 +78,24 @@ services.AddTransient<IStatsCollector, PrometheusStatsCollector>();
// paywall
services.AddVoidPaywall();
// users
services.AddTransient<IUserStore, UserStore>();
services.AddTransient<IUserManager, UserManager>();
if (useRedis)
{
services.AddTransient<ICache, RedisCache>();
services.AddTransient<RedisStatsController>();
services.AddTransient<IStatsCollector>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddTransient<IStatsReporter>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddTransient<IPaywallStore, RedisPaywallStore>();
}
else
{
services.AddMemoryCache();
services.AddTransient<ICache, InMemoryCache>();
services.AddTransient<InMemoryStatsController>();
services.AddTransient<IStatsReporter>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddTransient<IStatsCollector>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddTransient<IPaywallStore, InMemoryPaywallStore>();
}
var app = builder.Build();
@ -103,4 +118,4 @@ app.UseEndpoints(ep =>
ep.MapFallbackToFile("index.html");
});
app.Run();
app.Run();

View File

@ -0,0 +1,5 @@
namespace VoidCat.Services.Abstractions;
public interface IAggregateStatsCollector : IStatsCollector
{
}

View File

@ -0,0 +1,10 @@
namespace VoidCat.Services.Abstractions;
public interface ICache
{
ValueTask<T?> Get<T>(string key);
ValueTask Set<T>(string key, T value, TimeSpan? expire = null);
ValueTask<string[]> GetList(string key);
ValueTask AddToList(string key, string value);
}

View File

@ -11,34 +11,4 @@ public interface IFileStore
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
IAsyncEnumerable<PublicVoidFile> ListFiles();
}
public sealed record IngressPayload(Stream InStream, VoidFileMeta Meta, string Hash)
{
public Guid? Id { get; init; }
public Guid? EditSecret { get; init; }
public bool IsAppend => Id.HasValue && EditSecret.HasValue;
}
public sealed record EgressRequest(Guid Id, IEnumerable<RangeRequest> Ranges)
{
}
public sealed record RangeRequest(long? TotalSize, long? Start, long? End)
{
private const long DefaultBufferSize = 1024L * 512L;
public long? Size
=> Start.HasValue ? (End ?? Math.Min(TotalSize!.Value, Start.Value + DefaultBufferSize)) - Start.Value : End;
public bool IsForFullFile
=> Start is 0 && !End.HasValue;
/// <summary>
/// Return Content-Range header content for this range
/// </summary>
/// <returns></returns>
public string ToContentRange()
=> $"bytes {Start}-{End ?? (Start + Size - 1L)}/{TotalSize?.ToString() ?? "*"}";
}

View File

@ -1,4 +1,3 @@
using VoidCat.Model;
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
@ -6,20 +5,4 @@ namespace VoidCat.Services.Abstractions;
public interface IPaywallFactory
{
ValueTask<IPaywallProvider> CreateProvider(PaywallServices svc);
}
public interface IPaywallProvider
{
ValueTask<PaywallOrder?> CreateOrder(PublicVoidFile file);
ValueTask<PaywallOrder?> GetOrderStatus(Guid id);
}
public interface IPaywallStore
{
ValueTask<PaywallOrder?> GetOrder(Guid id);
ValueTask SaveOrder(PaywallOrder order);
ValueTask<PaywallConfig?> GetConfig(Guid id);
ValueTask SetConfig(Guid id, PaywallConfig config);
}
}

View File

@ -0,0 +1,11 @@
using VoidCat.Model;
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
public interface IPaywallProvider
{
ValueTask<PaywallOrder?> CreateOrder(PublicVoidFile file);
ValueTask<PaywallOrder?> GetOrderStatus(Guid id);
}

View File

@ -0,0 +1,12 @@
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
public interface IPaywallStore
{
ValueTask<PaywallOrder?> GetOrder(Guid id);
ValueTask SaveOrder(PaywallOrder order);
ValueTask<PaywallConfig?> GetConfig(Guid id);
ValueTask SetConfig(Guid id, PaywallConfig config);
}

View File

@ -1,19 +1,7 @@
namespace VoidCat.Services.Abstractions;
public interface IAggregateStatsCollector : IStatsCollector
{
}
public interface IStatsCollector
{
ValueTask TrackIngress(Guid id, ulong amount);
ValueTask TrackEgress(Guid id, ulong amount);
}
public interface IStatsReporter
{
ValueTask<Bandwidth> GetBandwidth();
ValueTask<Bandwidth> GetBandwidth(Guid id);
}
public sealed record Bandwidth(ulong Ingress, ulong Egress);
}

View File

@ -0,0 +1,9 @@
using VoidCat.Model;
namespace VoidCat.Services.Abstractions;
public interface IStatsReporter
{
ValueTask<Bandwidth> GetBandwidth();
ValueTask<Bandwidth> GetBandwidth(Guid id);
}

View File

@ -1,10 +1,9 @@
namespace VoidCat.Services.Abstractions;
using VoidCat.Model;
namespace VoidCat.Services.Abstractions;
public interface IUserManager
{
ValueTask<VoidUser> Get(string email, string password);
ValueTask<VoidUser> Get(Guid id);
ValueTask Set(VoidUser user);
ValueTask<VoidUser> Login(string username, string password);
ValueTask<VoidUser> Register(string username, string password);
}
public sealed record VoidUser(Guid Id, string Email, string PasswordHash);

View File

@ -0,0 +1,11 @@
using VoidCat.Model;
namespace VoidCat.Services.Abstractions;
public interface IUserStore
{
ValueTask<Guid?> LookupUser(string email);
ValueTask<VoidUser?> Get(Guid id);
ValueTask Set(VoidUser user);
IAsyncEnumerable<VoidUser> ListUsers(CancellationToken cts);
}

View File

@ -0,0 +1,19 @@
namespace VoidCat.Services.Abstractions;
public sealed record RangeRequest(long? TotalSize, long? Start, long? End)
{
private const long DefaultBufferSize = 1024L * 512L;
public long? Size
=> Start.HasValue ? (End ?? Math.Min(TotalSize!.Value, Start.Value + DefaultBufferSize)) - Start.Value : End;
public bool IsForFullFile
=> Start is 0 && !End.HasValue;
/// <summary>
/// Return Content-Range header content for this range
/// </summary>
/// <returns></returns>
public string ToContentRange()
=> $"bytes {Start}-{End ?? (Start + Size - 1L)}/{TotalSize?.ToString() ?? "*"}";
}

View File

@ -0,0 +1,46 @@
using Microsoft.Extensions.Caching.Memory;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.InMemory;
public class InMemoryCache : ICache
{
private readonly IMemoryCache _cache;
public InMemoryCache(IMemoryCache cache)
{
_cache = cache;
}
public ValueTask<T?> Get<T>(string key)
{
return ValueTask.FromResult(_cache.Get<T?>(key));
}
public ValueTask Set<T>(string key, T value, TimeSpan? expire = null)
{
if (expire.HasValue)
{
_cache.Set(key, value, expire.Value);
}
else
{
_cache.Set(key, value);
}
return ValueTask.CompletedTask;
}
public ValueTask<string[]> GetList(string key)
{
return ValueTask.FromResult(_cache.Get<string[]>(key));
}
public ValueTask AddToList(string key, string value)
{
var list = new HashSet<string>(GetList(key).Result);
list.Add(value);
_cache.Set(key, list.ToArray());
return ValueTask.CompletedTask;
}
}

View File

@ -1,38 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.InMemory;
public class InMemoryPaywallStore : IPaywallStore
{
private readonly IMemoryCache _cache;
public InMemoryPaywallStore(IMemoryCache cache)
{
_cache = cache;
}
public ValueTask<PaywallConfig?> GetConfig(Guid id)
{
return ValueTask.FromResult(_cache.Get(id) as PaywallConfig);
}
public ValueTask SetConfig(Guid id, PaywallConfig config)
{
_cache.Set(id, config);
return ValueTask.CompletedTask;
}
public ValueTask<PaywallOrder?> GetOrder(Guid id)
{
return ValueTask.FromResult(_cache.Get(id) as PaywallOrder);
}
public ValueTask SaveOrder(PaywallOrder order)
{
_cache.Set(order.Id, order,
order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5));
return ValueTask.CompletedTask;
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.Caching.Memory;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.InMemory;

View File

@ -27,6 +27,7 @@ public static class Paywall
public static IServiceCollection AddVoidPaywall(this IServiceCollection services)
{
services.AddTransient<IPaywallFactory, PaywallFactory>();
services.AddTransient<IPaywallStore, PaywallStore>();
// strike
services.AddTransient<StrikeApi>();

View File

@ -0,0 +1,44 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
public class PaywallStore : IPaywallStore
{
private readonly ICache _cache;
public PaywallStore(ICache database)
{
_cache = database;
}
public async ValueTask<PaywallConfig?> GetConfig(Guid id)
{
var cfg = await _cache.Get<PaywallConfig>(ConfigKey(id));
return cfg?.Service switch
{
PaywallServices.None => await _cache.Get<NoPaywallConfig>(ConfigKey(id)),
PaywallServices.Strike => await _cache.Get<StrikePaywallConfig>(ConfigKey(id)),
_ => default
};
}
public async ValueTask SetConfig(Guid id, PaywallConfig config)
{
await _cache.Set(ConfigKey(id), config);
}
public async ValueTask<PaywallOrder?> GetOrder(Guid id)
{
return await _cache.Get<PaywallOrder>(OrderKey(id));
}
public async ValueTask SaveOrder(PaywallOrder order)
{
await _cache.Set(OrderKey(order.Id), order,
order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5));
}
private string ConfigKey(Guid id) => $"paywall:config:{id}";
private string OrderKey(Guid id) => $"paywall:order:{id}";
}

View File

@ -0,0 +1,37 @@
using Newtonsoft.Json;
using StackExchange.Redis;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Redis;
public class RedisCache : ICache
{
private readonly IDatabase _db;
public RedisCache(IDatabase db)
{
_db = db;
}
public async ValueTask<T?> Get<T>(string key)
{
var json = await _db.StringGetAsync(key);
return json.HasValue ? JsonConvert.DeserializeObject<T>(json) : default;
}
public async ValueTask Set<T>(string key, T value, TimeSpan? expire = null)
{
var json = JsonConvert.SerializeObject(value);
await _db.StringSetAsync(key, json, expire);
}
public async ValueTask<string[]> GetList(string key)
{
return (await _db.SetMembersAsync(key)).ToStringArray();
}
public async ValueTask AddToList(string key, string value)
{
await _db.SetAddAsync(key, value);
}
}

View File

@ -1,53 +0,0 @@
using Newtonsoft.Json;
using StackExchange.Redis;
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Redis;
public class RedisPaywallStore : IPaywallStore
{
private readonly IDatabase _database;
public RedisPaywallStore(IDatabase database)
{
_database = database;
}
public async ValueTask<PaywallConfig?> GetConfig(Guid id)
{
var json = await _database.StringGetAsync(ConfigKey(id));
var cfg = json.HasValue ? JsonConvert.DeserializeObject<PaywallBlank>(json) : default;
return cfg?.Service switch
{
PaywallServices.None => JsonConvert.DeserializeObject<NoPaywallConfig>(json),
PaywallServices.Strike => JsonConvert.DeserializeObject<StrikePaywallConfig>(json),
_ => default
};
}
public async ValueTask SetConfig(Guid id, PaywallConfig config)
{
await _database.StringSetAsync(ConfigKey(id), JsonConvert.SerializeObject(config));
}
public async ValueTask<PaywallOrder?> GetOrder(Guid id)
{
var json = await _database.StringGetAsync(OrderKey(id));
return json.HasValue ? JsonConvert.DeserializeObject<PaywallOrder>(json) : default;
}
public async ValueTask SaveOrder(PaywallOrder order)
{
await _database.StringSetAsync(OrderKey(order.Id), JsonConvert.SerializeObject(order),
order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5));
}
private RedisKey ConfigKey(Guid id) => $"paywall:config:{id}";
private RedisKey OrderKey(Guid id) => $"paywall:order:{id}";
internal class PaywallBlank
{
public PaywallServices Service { get; init; }
}
}

View File

@ -1,4 +1,5 @@
using StackExchange.Redis;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Redis;

View File

@ -1,6 +1,6 @@
using VoidCat.Services.Abstractions;
namespace VoidCat.Services;
namespace VoidCat.Services.Stats;
public class AggregateStatsCollector : IAggregateStatsCollector
{

View File

@ -1,7 +1,7 @@
using Prometheus;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services;
namespace VoidCat.Services.Stats;
public class PrometheusStatsCollector : IStatsCollector
{

View File

@ -0,0 +1,35 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;
public class UserManager : IUserManager
{
private readonly IUserStore _store;
public UserManager(IUserStore store)
{
_store = store;
}
public async ValueTask<VoidUser> 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.Get(userId.Value);
if (!(user?.CheckPassword(password) ?? false)) throw new InvalidOperationException("User does not exist");
return user;
}
public async ValueTask<VoidUser> Register(string email, string password)
{
var existingUser = await _store.LookupUser(email);
if (existingUser != default) throw new InvalidOperationException("User already exists");
var newUser = new VoidUser(Guid.NewGuid(), email, password.HashPassword());
await _store.Set(newUser);
return newUser;
}
}

View File

@ -0,0 +1,55 @@
using System.Runtime.CompilerServices;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Users;
public class UserStore : IUserStore
{
private const string UserList = "users";
private readonly ICache _cache;
public UserStore(ICache cache)
{
_cache = cache;
}
public async ValueTask<Guid?> LookupUser(string email)
{
return await _cache.Get<Guid>(MapKey(email));
}
public async ValueTask<VoidUser?> Get(Guid id)
{
return await _cache.Get<VoidUser>(MapKey(id));
}
public async ValueTask Set(VoidUser user)
{
await _cache.Set(MapKey(user.Id), user);
await _cache.AddToList(UserList, user.Id.ToString());
await _cache.Set(MapKey(user.Email), user.Id.ToString());
}
public async IAsyncEnumerable<VoidUser> ListUsers([EnumeratorCancellation] CancellationToken cts = default)
{
var users = (await _cache.GetList(UserList))?.Select(Guid.Parse);
if (users != default)
{
while (!cts.IsCancellationRequested)
{
var loadUsers = await Task.WhenAll(users.Select(async a => await Get(a)));
foreach (var user in loadUsers)
{
if (user != default)
{
yield return user;
}
}
}
}
}
private static string MapKey(Guid id) => $"user:{id}";
private static string MapKey(string email) => $"user:email:{email}";
}

View File

@ -10,7 +10,7 @@ export function Login(props) {
async function login(e) {
e.target.disabled = true;
let req = await fetch("/login", {
let req = await fetch("/auth/login", {
method: "POST",
body: JSON.stringify({
username, password