Files
Abyss/Abyss/Components/Services/Security/UserService.cs
2025-10-27 12:25:04 +08:00

293 lines
9.3 KiB
C#

// UserService.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Abyss.Components.Services.Misc;
using Abyss.Model.Security;
using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography;
using SQLite;
using Task = System.Threading.Tasks.Task;
namespace Abyss.Components.Services.Security;
public class UserService
{
private readonly ILogger<UserService> _logger;
private readonly IMemoryCache _cache;
private readonly SQLiteAsyncConnection _database;
private readonly Dictionary<int, string> _userAnnounces = new();
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
{
_logger = logger;
_cache = cache;
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<User>().Wait();
if (config.DebugMode == "Debug")
_cache.Set("abyss", $"1@127.0.0.1", DateTimeOffset.Now.AddHours(1));
// Test token, can only be used locally. Will be destroyed in one hour.
}
public string? GetAnnounce(int id)
{
return _userAnnounces.GetValueOrDefault(id);
}
public bool SetAnnounce(int id, string? value, string token, string ip)
{
if (Validate(token, ip) == -1)
{
return false;
}
if (value == null)
{
_userAnnounces.Remove(id);
return true;
}
_userAnnounces[id] = value;
return true;
}
public async Task<bool> IsEmptyUser()
{
return await _database.Table<User>().CountAsync() == 0;
}
public async Task<string?> OpenUserAsync(string user, string token, string? bindIp, string ip)
{
var caller = Validate(token, ip);
if (caller != 1)
{
return null;
}
var target = await QueryUser(user);
if (target == null)
{
return null;
}
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? ip : bindIp;
var t = CreateToken(target.Uuid!.Value, ipToBind, TimeSpan.FromHours(1));
_logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user,
ipToBind, ip);
return t;
}
public async Task<bool> CreateUserAsync(string user, UserCreating creating, string ip)
{
// Valid token
var r = await Verify(user, creating.Response, ip);
if (r == null)
return false;
// User exists ?
var cu = await QueryUser(creating.Name);
if (cu != null)
return false;
// Valid username string
if (!IsAlphanumeric(creating.Name))
return false;
// Valid parent && Privilege
var ou = await QueryUser(Validate(r, ip));
if (creating.Privilege > ou?.Privilege || ou == null)
return false;
await AddUserAsync(new User
{
Username = creating.Name,
ParentId = ou.Uuid!.Value,
Privilege = creating.Privilege,
PublicKey = creating.PublicKey,
});
Destroy(r);
return true;
}
public async Task<string?> Challenge(string user)
{
var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
return null;
if (_cache.TryGetValue(u.Uuid!.Value, out _)) // The previous challenge has not yet expired
_cache.Remove(u.Uuid);
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
_cache.Set(u.Uuid, c, DateTimeOffset.Now.AddMinutes(1));
return c;
}
// The challenge source and response source are not necessarily required to be the same,
// but the source that obtains the token must be the same as the source that uses the token in the future
public async Task<string?> Verify(string user, string response, string ip)
{
var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
{
return null;
}
if (_cache.TryGetValue(u.Uuid!.Value, out string? challenge))
{
bool isVerified = VerifySignature(
PublicKey.Import(
SignatureAlgorithm.Ed25519,
Convert.FromBase64String(u.PublicKey),
KeyBlobFormat.RawPublicKey),
Convert.FromBase64String(challenge ?? ""),
Convert.FromBase64String(response));
if (!isVerified)
{
// Verification failed, set the challenge string to random to prevent duplicate verification
_cache.Set(u.Uuid, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1));
return null;
}
else
{
// Remove the challenge string and create a session
_cache.Remove(u.Uuid);
var s = GenerateRandomAsciiString(64);
_cache.Set(s, $"{u.Uuid}@{ip}", DateTimeOffset.Now.AddDays(1));
_logger.LogInformation($"Verified {u.Uuid}@{ip}, Name: {u.Username}");
return s;
}
}
return null;
}
// Id >= 1 : Success, Uid
// Id == -1: Failed
public int Validate(string token, string ip)
{
if (_cache.TryGetValue(token, out string? userAndIp))
{
if (ip != userAndIp?.Split('@')[1] && ip != "127.0.0.1" && token != "abyss")
{
_logger.LogError($"Token used from another Host: {token}");
Destroy(token);
return -1;
}
// _logger.LogInformation($"Validated {userAndIp}");
return Convert.ToInt32(userAndIp?.Split('@')[0]);
}
_logger.LogWarning($"Validation failed {token}");
return -1;
}
public void Destroy(string token)
{
_cache.Remove(token);
}
public async Task<User?> QueryUser(int uid)
{
if (uid == -1)
return null;
var u = await _database.Table<User>().Where(x => x.Uuid == uid).FirstOrDefaultAsync();
return u;
}
public async Task<User?> QueryUser(string username)
{
var u = await _database.Table<User>().Where(x => x.Username == username).FirstOrDefaultAsync();
return u;
}
public async Task AddUserAsync(User user)
{
await _database.InsertAsync(user);
_logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}");
}
public static Key GenerateKeyPair()
{
var algorithm = SignatureAlgorithm.Ed25519;
var creationParameters = new KeyCreationParameters
{
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
};
return Key.Create(algorithm, creationParameters);
}
public static string GenerateRandomAsciiString(int length)
{
const string asciiChars = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
using (var rng = RandomNumberGenerator.Create())
{
byte[] randomBytes = new byte[length];
rng.GetBytes(randomBytes);
char[] result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = asciiChars[randomBytes[i] % asciiChars.Length];
}
return new string(result);
}
}
public static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
{
var algorithm = SignatureAlgorithm.Ed25519;
return algorithm.Verify(publicKey, data, signature);
}
public async Task<bool> VerifyAny(byte[] data, byte[] signature)
{
var users = await _database.Table<User>().ToListAsync();
foreach (var u in users)
{
try
{
var pubKeyBytes = Convert.FromBase64String(u.PublicKey);
var pubKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
pubKeyBytes,
KeyBlobFormat.RawPublicKey);
if (VerifySignature(pubKey, data, signature))
{
_logger.LogInformation($"Signature verified using user {u.Username}");
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to import public key for {u.Username}");
}
}
return false;
}
public string CreateToken(int uid, string ip, TimeSpan lifetime)
{
var token = GenerateRandomAsciiString(64);
_cache.Set(token, $"{uid}@{ip}", DateTimeOffset.Now.Add(lifetime));
_logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes");
return token;
}
public static bool IsAlphanumeric(string input)
{
if (string.IsNullOrEmpty(input))
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
}
}