From 9b36f5dd973cedb2c72f0f818d067ff196edcf0f Mon Sep 17 00:00:00 2001 From: CoPokBl Date: Thu, 13 Jun 2024 13:53:36 +1000 Subject: [PATCH 1/7] Start passkeys --- API/v1/Account/AuthController.cs | 9 +++-- API/v1/Account/PasskeyController.cs | 25 ++++++++++++ Data/Schemas/SavedPasskey.cs | 9 +++++ Data/SerbleUtils.cs | 3 ++ Data/Storage/IStorageService.cs | 4 ++ Data/Storage/MySQL/MySqlPasskeys.cs | 49 +++++++++++++++++++++++ Data/Storage/MySQL/MySqlStorageService.cs | 7 ++++ Program.cs | 19 ++++++++- SerbleAPI.csproj | 3 +- 9 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 API/v1/Account/PasskeyController.cs create mode 100644 Data/Schemas/SavedPasskey.cs create mode 100644 Data/Storage/MySQL/MySqlPasskeys.cs diff --git a/API/v1/Account/AuthController.cs b/API/v1/Account/AuthController.cs index 8c5b26a..b4c78ab 100644 --- a/API/v1/Account/AuthController.cs +++ b/API/v1/Account/AuthController.cs @@ -8,10 +8,11 @@ namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/auth")] -public class AuthController : ControllerManager { - - [HttpGet] - public IActionResult Get([FromHeader] BasicAuthorizationHeader authorizationHeader) { +public class AuthController: ControllerManager { + + [HttpGet] // Keep for backwards compatibility + [HttpGet("password")] + public IActionResult PasswordAuth([FromHeader] BasicAuthorizationHeader authorizationHeader) { if (authorizationHeader.IsNull()) { return BadRequest("Authorization header is missing"); } diff --git a/API/v1/Account/PasskeyController.cs b/API/v1/Account/PasskeyController.cs new file mode 100644 index 0000000..9efc08c --- /dev/null +++ b/API/v1/Account/PasskeyController.cs @@ -0,0 +1,25 @@ +using Fido2NetLib; +using Microsoft.AspNetCore.Mvc; +using SerbleAPI.Data; +using SerbleAPI.Data.ApiDataSchemas; +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.API.v1.Account; + +[ApiController] +[Route("api/v1/auth/passkey")] +public class PasskeyController(IFido2 fido) : ControllerManager { + private IFido2 _fido = fido; + + [HttpPost("create")] + public IActionResult PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { + if (!auth.CheckAndGetInfo(out User? user, + out _, + ScopeHandler.ScopesEnum.FullAccess, + false)) { + return Unauthorized(); + } + + + } +} \ No newline at end of file diff --git a/Data/Schemas/SavedPasskey.cs b/Data/Schemas/SavedPasskey.cs new file mode 100644 index 0000000..fb0b719 --- /dev/null +++ b/Data/Schemas/SavedPasskey.cs @@ -0,0 +1,9 @@ +namespace SerbleAPI.Data.Schemas; + +public class SavedPasskey(string ownerId, byte[] credentialId, byte[] publicKey, int signCount, byte[] aaGuid) { + public string OwnerId = ownerId; + public byte[] CredentialId = credentialId; + public byte[] PublicKey = publicKey; + public int SignCount = signCount; + public byte[] AaGuid = aaGuid; +} \ No newline at end of file diff --git a/Data/SerbleUtils.cs b/Data/SerbleUtils.cs index 20f0f50..d3b5957 100644 --- a/Data/SerbleUtils.cs +++ b/Data/SerbleUtils.cs @@ -7,6 +7,9 @@ public static class SerbleUtils { public static string Base64Encode(string plainText) => Convert.ToBase64String(Encoding.UTF8.GetBytes(plainText)); + + public static string Base64Encode(this byte[] data) => + Convert.ToBase64String(data); public static string Base64Decode(string base64EncodedData) => Encoding.UTF8.GetString(Convert.FromBase64String(base64EncodedData)); diff --git a/Data/Storage/IStorageService.cs b/Data/Storage/IStorageService.cs index 213fe9f..90b55ff 100644 --- a/Data/Storage/IStorageService.cs +++ b/Data/Storage/IStorageService.cs @@ -36,4 +36,8 @@ public interface IStorageService { public void UpdateUserNoteContent(string userId, string noteId, string note); public void GetUserNoteContent(string userId, string noteId, out string? content); public void DeleteUserNote(string userId, string noteId); + + public void CreatePasskey(SavedPasskey key); + public void GetUsersPasskeys(string userId, out SavedPasskey[] keys); + public void IncrementPasskeySignCount(string userId, byte[] credId); } \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlPasskeys.cs b/Data/Storage/MySQL/MySqlPasskeys.cs new file mode 100644 index 0000000..280d529 --- /dev/null +++ b/Data/Storage/MySQL/MySqlPasskeys.cs @@ -0,0 +1,49 @@ +using MySql.Data.MySqlClient; +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Data.Storage.MySQL; + +public partial class MySqlStorageService { + public void CreatePasskey(SavedPasskey key) { + MySqlHelper.ExecuteNonQuery(_connectString!, + "INSERT INTO serblesite_user_passkeys(" + + "ownerid, credentialid, publickey, signcount, aaguid) " + + "VALUES(@ownerid, @credentialid, @publickey, @signcount, @aaguid)", + new MySqlParameter("@ownerid", key.OwnerId), + new MySqlParameter("@credentialid", key.CredentialId.Base64Encode()), + new MySqlParameter("@publickey", key.PublicKey.Base64Encode()), + new MySqlParameter("@signcount", key.SignCount), + new MySqlParameter("@aaguid", key.AaGuid.Base64Encode())); + } + + public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { + using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_user_passkeys WHERE ownerid=@id", + new MySqlParameter("@id", userId)); + if (!reader.Read()) { + keys = []; + return; + } + string? subId = reader.IsDBNull("subscriptionId") ? null : reader.GetString("subscriptionId"); + string? language = reader.IsDBNull("language") ? null : reader.GetString("language"); + user = new User { + Id = reader.GetString("id"), + Username = reader.GetString("username"), + Email = reader.GetString("email"), + VerifiedEmail = reader.GetBoolean("verifiedEmail"), + PasswordHash = reader.GetString("password"), + PermLevel = reader.GetInt32("permlevel"), + PermString = reader.GetString("permstring"), + StripeCustomerId = subId, + Language = language, + TotpEnabled = reader.GetBoolean("totp_enabled"), + TotpSecret = reader.IsDBNull("totp_secret") ? null : reader.GetString("totp_secret"), + PasswordSalt = reader.IsDBNull("password_salt") ? null : reader.GetString("password_salt") + }; + + reader.Close(); + } + + public void IncrementPasskeySignCount(string userId, byte[] credId) { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlStorageService.cs b/Data/Storage/MySQL/MySqlStorageService.cs index d096eda..bd15c79 100644 --- a/Data/Storage/MySQL/MySqlStorageService.cs +++ b/Data/Storage/MySQL/MySqlStorageService.cs @@ -59,6 +59,13 @@ appid VARCHAR(64), "noteid VARCHAR(64), " + "note MEDIUMTEXT, " + "FOREIGN KEY (user) REFERENCES serblesite_users(id))"); + SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_passkeys( + ownerid VARCHAR(64), + credentialid TEXT, + publickey TEXT, + signcount INT, + aaguid VARCHAR(36) + )"); } private void SendMySqlStatement(string statement) { diff --git a/Program.cs b/Program.cs index bb781f3..88b5866 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,5 @@ using System.Security; +using Fido2NetLib; using GeneralPurposeLib; using SerbleAPI.Data; using SerbleAPI.Data.Raw; @@ -20,6 +21,8 @@ public static class Program { { "http_authorization_token", "my very secure auth token" }, { "http_url", "https://myverysecurestoragebackend.io/" }, { "my_host" , "https://theplacewherethisappisaccessable.com/" }, + { "my_domain", "serble.net" }, + { "fido_origins", "https://serble.net;https://www.serble.net;https://serble" }, { "token_issuer", "CoPokBl" }, { "token_audience", "Privileged Users" }, { "token_secret" , Guid.NewGuid().ToString() }, @@ -46,7 +49,9 @@ public static class Program { { "stripe_testing_webhook_secret", "we_**************" }, { "stripe_premium_sub_id", "SerblePremiumPriceID" }, { "stripe_testing_premium_sub_id", "SerblePremiumPriceID" }, - { "give_products_to_non_admins_while_testing", "false" } + { "give_products_to_non_admins_while_testing", "false" }, + { "fido_mds_cache_dir", "./mdscache" }, + { "server_icon", "https://serble.net/assets/images/icon.png" } }; public static Dictionary? Config; public static IStorageService? StorageService; @@ -212,6 +217,18 @@ private static int Run(string[] args) { builder.Services.AddSwaggerGen(); builder.Services.AddEndpointsApiExplorer(); builder.WebHost.UseUrls(Config["bind_url"]); + + builder.Services.AddFido2(options => { + options.ServerDomain = Config["my_domain"]; + options.ServerName = "FIDO2 Test"; + options.Origins = Config["fido_origins"].Split(';').ToHashSet(); + options.TimestampDriftTolerance = 1000 * 60 * 5; + options.ServerIcon = Config["server_icon_url"]; + }).AddCachedMetadataService(config => { + config.AddFidoMetadataRepository(_ => { + + }); + }); } catch (Exception e) { Logger.Error(e); diff --git a/SerbleAPI.csproj b/SerbleAPI.csproj index 55c13ef..b451593 100644 --- a/SerbleAPI.csproj +++ b/SerbleAPI.csproj @@ -1,12 +1,13 @@ - net6.0 + net8.0 enable enable + From 2fecef187ef0e083e9596b8f041c5e697867061f Mon Sep 17 00:00:00 2001 From: copokbl Date: Thu, 13 Jun 2024 23:13:32 +1000 Subject: [PATCH 2/7] More passkey stuff like sql, maybe ill finish if i get a fricken second --- API/v1/Account/PasskeyController.cs | 91 ++++++++++++++++++++++- Data/Schemas/SavedPasskey.cs | 22 ++++-- Data/SerbleUtils.cs | 39 ++++++++++ Data/Storage/FileStorageService.cs | 16 ++++ Data/Storage/IStorageService.cs | 1 + Data/Storage/MySQL/MySqlPasskeys.cs | 77 ++++++++++++------- Data/Storage/MySQL/MySqlStorageService.cs | 22 ++++-- 7 files changed, 229 insertions(+), 39 deletions(-) diff --git a/API/v1/Account/PasskeyController.cs b/API/v1/Account/PasskeyController.cs index 9efc08c..4e4fe93 100644 --- a/API/v1/Account/PasskeyController.cs +++ b/API/v1/Account/PasskeyController.cs @@ -1,4 +1,6 @@ +using System.Text; using Fido2NetLib; +using Fido2NetLib.Objects; using Microsoft.AspNetCore.Mvc; using SerbleAPI.Data; using SerbleAPI.Data.ApiDataSchemas; @@ -13,6 +15,16 @@ public class PasskeyController(IFido2 fido) : ControllerManager { [HttpPost("create")] public IActionResult PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { + + } + + [HttpPost("credentialoptions")] + public IActionResult MakeCredentialOptions( + [FromHeader] SerbleAuthorizationHeader auth, + [FromForm] string attType, + [FromForm] string authType, + [FromForm] string residentKey, + [FromForm] string userVerification) { if (!auth.CheckAndGetInfo(out User? user, out _, ScopeHandler.ScopesEnum.FullAccess, @@ -20,6 +32,83 @@ public IActionResult PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { return Unauthorized(); } - + // 3. Create options + AuthenticatorSelection authenticatorSelection = new() { + ResidentKey = residentKey.ToEnum(), + UserVerification = userVerification.ToEnum() + }; + + if (!string.IsNullOrEmpty(authType)) { + authenticatorSelection.AuthenticatorAttachment = authType.ToEnum(); + } + + AuthenticationExtensionsClientInputs exts = new() { + Extensions = true, + UserVerificationMethod = true, + DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() { Attestation = attType }, + CredProps = true + }; + + Fido2User fidoUser = new() { + Name = user.Username, + DisplayName = user.Username, + Id = Encoding.UTF8.GetBytes(user.Id) + }; + + IReadOnlyList excludeCreds = []; + + CredentialCreateOptions options = _fido.RequestNewCredential(fidoUser, excludeCreds, authenticatorSelection, attType.ToEnum(), exts); + + // 4. Temporarily store options, session/in-memory cache/redis/db + HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); + + // 5. return options to client + return Json(options); + } + + [HttpPost("credential")] + public async Task MakeCredentialOptions([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) { + try { + // 1. get the options we sent the client + string? jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions"); + CredentialCreateOptions? options = CredentialCreateOptions.FromJson(jsonOptions); + + // 2. Create callback so that lib can verify credential id is unique to this user + IsCredentialIdUniqueToUserAsyncDelegate callback = static async (args, cancellationToken) => { + Program.StorageService!. + var users = await DemoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken); + if (users.Count > 0) + return false; + + return true; + }; + + // 2. Verify and make the credentials + MakeNewCredentialResult success = await _fido.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken); + + // 3. Store the credentials in db + string userId = Encoding.UTF8.GetString(success.Result!.User.Id); + SavedPasskey cred = new() { + OwnerId = userId, + CredentialId = success.Result!.Id, + PublicKey = success.Result.PublicKey, + AaGuid = success.Result.AaGuid, + AttestationClientDataJson = success.Result.AttestationClientDataJson, + Descriptor = new PublicKeyCredentialDescriptor(success.Result.Id), + SignCount = success.Result.SignCount, + AttestationFormat = success.Result.AttestationFormat, + Transports = success.Result.Transports, + IsBackupEligible = success.Result.IsBackupEligible, + IsBackedUp = success.Result.IsBackedUp, + AttestationObject = success.Result.AttestationObject, + DevicePublicKeys = [success.Result.DevicePublicKey] + }; + + Program.StorageService!.CreatePasskey(cred); + return Json(success); + } + catch (Exception e) { + return Json(new MakeNewCredentialResult(status: "error", errorMessage: FormatException(e), result: null)); + } } } \ No newline at end of file diff --git a/Data/Schemas/SavedPasskey.cs b/Data/Schemas/SavedPasskey.cs index fb0b719..77c62a9 100644 --- a/Data/Schemas/SavedPasskey.cs +++ b/Data/Schemas/SavedPasskey.cs @@ -1,9 +1,19 @@ +using Fido2NetLib.Objects; + namespace SerbleAPI.Data.Schemas; -public class SavedPasskey(string ownerId, byte[] credentialId, byte[] publicKey, int signCount, byte[] aaGuid) { - public string OwnerId = ownerId; - public byte[] CredentialId = credentialId; - public byte[] PublicKey = publicKey; - public int SignCount = signCount; - public byte[] AaGuid = aaGuid; +public class SavedPasskey { + public string? OwnerId; + public byte[]? CredentialId; + public byte[]? PublicKey; + public uint SignCount = 0; + public Guid? AaGuid; + public byte[]? AttestationClientDataJson; + public PublicKeyCredentialDescriptor? Descriptor; + public string? AttestationFormat; + public AuthenticatorTransport[]? Transports; + public bool IsBackupEligible; + public bool IsBackedUp; + public byte[]? AttestationObject; + public byte[][]? DevicePublicKeys; } \ No newline at end of file diff --git a/Data/SerbleUtils.cs b/Data/SerbleUtils.cs index d3b5957..3483a0a 100644 --- a/Data/SerbleUtils.cs +++ b/Data/SerbleUtils.cs @@ -24,4 +24,43 @@ public static string RandomString(int length) { .Select(s => s[_random.Next(s.Length)]).ToArray()); } + /// + /// T must be an enum. + /// + /// + /// + /// + public static int ToBitmask(this T[] e) { + return e.Aggregate(0, (current, en) => current | Convert.ToInt32(en)); + } + + public static T[] FromBitmask(int mask) { + return Enum.GetValues(typeof(T)).Cast().Where(e => (mask & Convert.ToInt32(e)) != 0).ToArray(); + } + + public static int GetIndex(this Enum val) { + return Array.IndexOf(Enum.GetValues(val.GetType()), val); + } + + public static T EnumFromIndex(int index) { + return (T) Enum.GetValues(typeof(T)).GetValue(index)!; + } + + public static string StringifyMda(this byte[][] arr) { + StringBuilder sb = new(); + foreach (byte[] bytes in arr) { + sb.Append(bytes.Base64Encode()); + sb.Append(','); + } + return sb.ToString(); + } + + public static byte[][] ParseMda(this string str, Func parse) { + string[] split = str.Split(','); + byte[][] arr = new byte[split.Length][]; + for (int i = 0; i < split.Length; i++) { + arr[i] = parse(split[i]); + } + return arr; + } } \ No newline at end of file diff --git a/Data/Storage/FileStorageService.cs b/Data/Storage/FileStorageService.cs index 98986fe..e84c63e 100644 --- a/Data/Storage/FileStorageService.cs +++ b/Data/Storage/FileStorageService.cs @@ -222,4 +222,20 @@ public void GetUserNoteContent(string userId, string noteId, out string? content public void DeleteUserNote(string userId, string noteId) { _userNotes.RemoveAll(n => n.Item1 == userId && n.Item2 == noteId); } + + public void CreatePasskey(SavedPasskey key) { + throw new NotImplementedException(); + } + + public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { + throw new NotImplementedException(); + } + + public void IncrementPasskeySignCount(string userId, byte[] credId) { + throw new NotImplementedException(); + } + + public void GetUserIdFromCredentialId(byte[] credId, out string? userId) { + throw new NotImplementedException(); + } } diff --git a/Data/Storage/IStorageService.cs b/Data/Storage/IStorageService.cs index 90b55ff..6eb661b 100644 --- a/Data/Storage/IStorageService.cs +++ b/Data/Storage/IStorageService.cs @@ -40,4 +40,5 @@ public interface IStorageService { public void CreatePasskey(SavedPasskey key); public void GetUsersPasskeys(string userId, out SavedPasskey[] keys); public void IncrementPasskeySignCount(string userId, byte[] credId); + public void GetUserIdFromCredentialId(byte[] credId, out string? userId); } \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlPasskeys.cs b/Data/Storage/MySQL/MySqlPasskeys.cs index 280d529..5eb1f3c 100644 --- a/Data/Storage/MySQL/MySqlPasskeys.cs +++ b/Data/Storage/MySQL/MySqlPasskeys.cs @@ -1,3 +1,4 @@ +using Fido2NetLib.Objects; using MySql.Data.MySqlClient; using SerbleAPI.Data.Schemas; @@ -5,15 +6,25 @@ namespace SerbleAPI.Data.Storage.MySQL; public partial class MySqlStorageService { public void CreatePasskey(SavedPasskey key) { - MySqlHelper.ExecuteNonQuery(_connectString!, - "INSERT INTO serblesite_user_passkeys(" + - "ownerid, credentialid, publickey, signcount, aaguid) " + - "VALUES(@ownerid, @credentialid, @publickey, @signcount, @aaguid)", - new MySqlParameter("@ownerid", key.OwnerId), - new MySqlParameter("@credentialid", key.CredentialId.Base64Encode()), - new MySqlParameter("@publickey", key.PublicKey.Base64Encode()), - new MySqlParameter("@signcount", key.SignCount), - new MySqlParameter("@aaguid", key.AaGuid.Base64Encode())); + using MySqlCommand cmd = new("INSERT INTO serblesite_user_passkeys" + + "(owner_id, credential_id, public_key, sign_count, aa_guid, attes_client_data_json, descriptor_type, descriptor_id, descriptor_transports, attes_format, transports, backup_eligible, backed_up, attes_object, device_public_keys) VALUES" + + "(@owner_id, @credential_id, @public_key, @sign_count, @aa_guid, @attes_client_data_json, @descriptor_type, @descriptor_id, @descriptor_transports, @attes_format, @transports, @backup_eligible, @backed_up, @attes_object, @device_public_keys)", new MySqlConnection(_connectString)); + cmd.Parameters.AddWithValue("@owner_id", key.OwnerId); + cmd.Parameters.AddWithValue("@credential_id", Convert.ToBase64String(key.CredentialId!)); + cmd.Parameters.AddWithValue("@public_key", Convert.ToBase64String(key.PublicKey!)); + cmd.Parameters.AddWithValue("@sign_count", key.SignCount); + cmd.Parameters.AddWithValue("@aa_guid", key.AaGuid!.Value.ToString()); + cmd.Parameters.AddWithValue("@attes_client_data_json", Convert.ToBase64String(key.AttestationClientDataJson!)); + cmd.Parameters.AddWithValue("@descriptor_type", key.Descriptor!.Type.GetIndex()); + cmd.Parameters.AddWithValue("@descriptor_id", Convert.ToBase64String(key.Descriptor!.Id)); + cmd.Parameters.AddWithValue("@descriptor_transports", key.Descriptor!.Transports); + cmd.Parameters.AddWithValue("@attes_format", key.AttestationFormat); + cmd.Parameters.AddWithValue("@transports", key.Transports!.ToBitmask()); + cmd.Parameters.AddWithValue("@backup_eligible", key.IsBackupEligible); + cmd.Parameters.AddWithValue("@backed_up", key.IsBackedUp); + cmd.Parameters.AddWithValue("@attes_object", Convert.ToBase64String(key.AttestationObject!)); + cmd.Parameters.AddWithValue("@device_public_keys", key.DevicePublicKeys!.StringifyMda()); + cmd.ExecuteNonQuery(); } public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { @@ -23,27 +34,41 @@ public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { keys = []; return; } - string? subId = reader.IsDBNull("subscriptionId") ? null : reader.GetString("subscriptionId"); - string? language = reader.IsDBNull("language") ? null : reader.GetString("language"); - user = new User { - Id = reader.GetString("id"), - Username = reader.GetString("username"), - Email = reader.GetString("email"), - VerifiedEmail = reader.GetBoolean("verifiedEmail"), - PasswordHash = reader.GetString("password"), - PermLevel = reader.GetInt32("permlevel"), - PermString = reader.GetString("permstring"), - StripeCustomerId = subId, - Language = language, - TotpEnabled = reader.GetBoolean("totp_enabled"), - TotpSecret = reader.IsDBNull("totp_secret") ? null : reader.GetString("totp_secret"), - PasswordSalt = reader.IsDBNull("password_salt") ? null : reader.GetString("password_salt") - }; + + List keyList = []; + do { + keyList.Add(ReadPasskey(reader)); + } while (reader.Read()); + + keys = keyList.ToArray(); + } - reader.Close(); + private static SavedPasskey ReadPasskey(MySqlDataReader reader) { + return new SavedPasskey { + OwnerId = reader.GetString("owner_id"), + CredentialId = Convert.FromBase64String(reader.GetString("credential_id")), + PublicKey = Convert.FromBase64String(reader.GetString("public_key")), + SignCount = reader.GetUInt32("sign_count"), + AaGuid = Guid.Parse(reader.GetString("aa_guid")), + AttestationClientDataJson = Convert.FromBase64String(reader.GetString("attes_client_data_json")), + Descriptor = new PublicKeyCredentialDescriptor( + SerbleUtils.EnumFromIndex(reader.GetInt32("descriptor_type")), + Convert.FromBase64String(reader.GetString("descriptor_id")), + SerbleUtils.FromBitmask(reader.GetInt32("descriptor_transports"))), + AttestationFormat = reader.GetString("attes_format"), + Transports = SerbleUtils.FromBitmask(reader.GetInt32("transports")), + IsBackupEligible = reader.GetBoolean("backup_eligible"), + IsBackedUp = reader.GetBoolean("backed_up"), + AttestationObject = Convert.FromBase64String(reader.GetString("attes_object")), + DevicePublicKeys = reader.GetString("device_public_keys").ParseMda(Convert.FromBase64String) + }; } public void IncrementPasskeySignCount(string userId, byte[] credId) { throw new NotImplementedException(); } + + public void GetUserIdFromCredentialId(byte[] credId, out string? userId) { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlStorageService.cs b/Data/Storage/MySQL/MySqlStorageService.cs index bd15c79..d69f5bd 100644 --- a/Data/Storage/MySQL/MySqlStorageService.cs +++ b/Data/Storage/MySQL/MySqlStorageService.cs @@ -60,12 +60,22 @@ appid VARCHAR(64), "note MEDIUMTEXT, " + "FOREIGN KEY (user) REFERENCES serblesite_users(id))"); SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_passkeys( - ownerid VARCHAR(64), - credentialid TEXT, - publickey TEXT, - signcount INT, - aaguid VARCHAR(36) - )"); + owner_id VARCHAR(64), + credential_id TEXT, + public_key TEXT, + sign_count INT, + aa_guid VARCHAR(64), + attes_client_data_json TEXT, + descriptor_type INT, + descriptor_id TEXT, + descriptor_transports INT, + attes_format TEXT, + transports INT, + backup_eligible BOOL, + backed_up BOOL, + attes_object TEXT, + device_public_keys TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); } private void SendMySqlStatement(string statement) { From f19ef704eba419ac9726a1917348c7f517564e3f Mon Sep 17 00:00:00 2001 From: CoPokBl Date: Fri, 14 Jun 2024 14:43:51 +1000 Subject: [PATCH 3/7] Tristan controller and more passkeys incl login --- API/v1/Account/AuthController.cs | 84 ++++++++++++++++++++++- API/v1/Account/PasskeyController.cs | 22 +++--- API/v1/People/TristanController.cs | 51 ++++++++++++++ Data/Schemas/SavedPasskey.cs | 1 + Data/Storage/FileStorageService.cs | 10 ++- Data/Storage/IStorageService.cs | 5 +- Data/Storage/MySQL/MySqlPasskeys.cs | 21 ++++-- Data/Storage/MySQL/MySqlStorageService.cs | 1 + 8 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 API/v1/People/TristanController.cs diff --git a/API/v1/Account/AuthController.cs b/API/v1/Account/AuthController.cs index b4c78ab..1059c48 100644 --- a/API/v1/Account/AuthController.cs +++ b/API/v1/Account/AuthController.cs @@ -1,3 +1,6 @@ +using System.Text; +using Fido2NetLib; +using Fido2NetLib.Objects; using GeneralPurposeLib; using Microsoft.AspNetCore.Mvc; using SerbleAPI.Data; @@ -8,10 +11,10 @@ namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/auth")] -public class AuthController: ControllerManager { +public class AuthController(IFido2 fido): ControllerManager { - [HttpGet] // Keep for backwards compatibility - [HttpGet("password")] + [HttpGet("")] // Keep for backwards compatibility + [HttpPost("password")] public IActionResult PasswordAuth([FromHeader] BasicAuthorizationHeader authorizationHeader) { if (authorizationHeader.IsNull()) { return BadRequest("Authorization header is missing"); @@ -52,6 +55,81 @@ public IActionResult PasswordAuth([FromHeader] BasicAuthorizationHeader authoriz mfa_required = false }); } + + [HttpPost("passkey/assertion")] // Make Assertion + public async Task PasskeyAuth([FromBody] AuthenticatorAssertionRawResponse clientResponse, + CancellationToken cancellationToken) { + try { + // Get the assertion options we sent the client + string? jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions"); + AssertionOptions? options = AssertionOptions.FromJson(jsonOptions); + + // Get registered credential from database + Program.StorageService!.GetPasskey(clientResponse.Id, out SavedPasskey? creds); + if (creds == null) { + return BadRequest("Unknown passkey"); + } + + // Create callback to check if the user handle owns the credentialId + IsUserHandleOwnerOfCredentialIdAsync callback = static (args, _) => { + Program.StorageService.GetUsersPasskeys(Encoding.UTF8.GetString(args.UserHandle), out SavedPasskey[] storedCreds); + return Task.FromResult(storedCreds.Any(c => c.Descriptor!.Id.SequenceEqual(args.CredentialId))); // TODO: Should this be c.CredentialId + }; + + // Make the assertion + VerifyAssertionResult res = await fido.MakeAssertionAsync(clientResponse, options, creds.PublicKey!, creds.DevicePublicKeys!, creds.SignCount, callback, cancellationToken: cancellationToken); + Program.StorageService.SetPasskeySignCount(res.CredentialId, (int) res.SignCount); + + if (res.DevicePublicKey is not null) { + creds.DevicePublicKeys = creds.DevicePublicKeys!.Append(res.DevicePublicKey).ToArray(); + } + + return Json(res); + } + catch (Exception e) { + return BadRequest("Failed"); + } + } + + [HttpPost] + [Route("/assertionOptions")] + public ActionResult AssertionOptionsPost([FromForm] string username) { + try { + //var existingCredentials = new List(); + + // if (!string.IsNullOrEmpty(username)) { + // // 1. Get user from DB + // var user = DemoStorage.GetUser(username) ?? throw new ArgumentException("Username was not registered"); + // + // // 2. Get registered credentials from database + // existingCredentials = DemoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList(); + // } + + AuthenticationExtensionsClientInputs exts = new() { + Extensions = true, + UserVerificationMethod = true, + DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() + }; + + // 3. Create options + UserVerificationRequirement uv = string.IsNullOrEmpty(userVerification) ? UserVerificationRequirement.Discouraged : userVerification.ToEnum(); + AssertionOptions options = fido.GetAssertionOptions( + existingCredentials, + uv, + exts + ); + + // 4. Temporarily store options, session/in-memory cache/redis/db + HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson()); + + // 5. Return options to client + return Json(options); + } + + catch (Exception e) { + return Json(new AssertionOptions { Status = "error", ErrorMessage = FormatException(e) }); + } + } [HttpOptions] public ActionResult Options() { diff --git a/API/v1/Account/PasskeyController.cs b/API/v1/Account/PasskeyController.cs index 4e4fe93..15399f7 100644 --- a/API/v1/Account/PasskeyController.cs +++ b/API/v1/Account/PasskeyController.cs @@ -11,11 +11,10 @@ namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/auth/passkey")] public class PasskeyController(IFido2 fido) : ControllerManager { - private IFido2 _fido = fido; [HttpPost("create")] - public IActionResult PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { - + public Task PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { + throw new NotImplementedException(); // Keep this func? } [HttpPost("credentialoptions")] @@ -57,7 +56,7 @@ public IActionResult MakeCredentialOptions( IReadOnlyList excludeCreds = []; - CredentialCreateOptions options = _fido.RequestNewCredential(fidoUser, excludeCreds, authenticatorSelection, attType.ToEnum(), exts); + CredentialCreateOptions options = fido.RequestNewCredential(fidoUser, excludeCreds, authenticatorSelection, attType.ToEnum(), exts); // 4. Temporarily store options, session/in-memory cache/redis/db HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); @@ -74,22 +73,19 @@ public async Task MakeCredentialOptions([FromBody] AuthenticatorA CredentialCreateOptions? options = CredentialCreateOptions.FromJson(jsonOptions); // 2. Create callback so that lib can verify credential id is unique to this user - IsCredentialIdUniqueToUserAsyncDelegate callback = static async (args, cancellationToken) => { - Program.StorageService!. - var users = await DemoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken); - if (users.Count > 0) - return false; - - return true; + IsCredentialIdUniqueToUserAsyncDelegate callback = static (args, _) => { + Program.StorageService!.GetUserIdFromPasskeyId(args.CredentialId, out string? userId); + return Task.FromResult(userId == null); }; // 2. Verify and make the credentials - MakeNewCredentialResult success = await _fido.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken); + MakeNewCredentialResult success = await fido.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken); // 3. Store the credentials in db string userId = Encoding.UTF8.GetString(success.Result!.User.Id); SavedPasskey cred = new() { OwnerId = userId, + Name = "Passkey " + Guid.NewGuid(), // Give it a random now CredentialId = success.Result!.Id, PublicKey = success.Result.PublicKey, AaGuid = success.Result.AaGuid, @@ -108,7 +104,7 @@ public async Task MakeCredentialOptions([FromBody] AuthenticatorA return Json(success); } catch (Exception e) { - return Json(new MakeNewCredentialResult(status: "error", errorMessage: FormatException(e), result: null)); + return BadRequest("Failed to create credentials: " + e.Message); } } } \ No newline at end of file diff --git a/API/v1/People/TristanController.cs b/API/v1/People/TristanController.cs new file mode 100644 index 0000000..6ca5d28 --- /dev/null +++ b/API/v1/People/TristanController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SerbleAPI.API.v1.People; + +[ApiController] +[Route("api/v1/tristan/")] +public class TristanController : ControllerManager { + + [HttpGet] + public IActionResult Get() { + return Ok("Gotten what?"); + } + + [HttpGet("{arg}")] + public IActionResult Get(string arg) { + return Redirect($"Oh damn"); + } + + [HttpPost] + public IActionResult Post() { + return Ok("Not again"); + } + + [HttpPut] + public IActionResult Put() { + return Ok("I'd rather pull"); + } + + [HttpDelete] + public IActionResult Delete() { + return Ok($""); + } + + [HttpPatch] + public IActionResult Patch() { + return Ok("Why?"); + } + + [HttpOptions] + public ActionResult Options() { + HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); + return Ok("Can I pick a different one?"); + } + + [HttpOptions("{cat:int}")] + public ActionResult OptionsArg() { + HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); + return Ok("Can I pick a different one?"); + } + +} \ No newline at end of file diff --git a/Data/Schemas/SavedPasskey.cs b/Data/Schemas/SavedPasskey.cs index 77c62a9..f82f189 100644 --- a/Data/Schemas/SavedPasskey.cs +++ b/Data/Schemas/SavedPasskey.cs @@ -4,6 +4,7 @@ namespace SerbleAPI.Data.Schemas; public class SavedPasskey { public string? OwnerId; + public string? Name; public byte[]? CredentialId; public byte[]? PublicKey; public uint SignCount = 0; diff --git a/Data/Storage/FileStorageService.cs b/Data/Storage/FileStorageService.cs index e84c63e..ff3425e 100644 --- a/Data/Storage/FileStorageService.cs +++ b/Data/Storage/FileStorageService.cs @@ -231,11 +231,19 @@ public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { throw new NotImplementedException(); } + public void SetPasskeySignCount(byte[] credId, int val) { + throw new NotImplementedException(); + } + public void IncrementPasskeySignCount(string userId, byte[] credId) { throw new NotImplementedException(); } - public void GetUserIdFromCredentialId(byte[] credId, out string? userId) { + public void GetUserIdFromPasskeyId(byte[] credId, out string? userId) { + throw new NotImplementedException(); + } + + public void GetPasskey(byte[] credId, out SavedPasskey? key) { throw new NotImplementedException(); } } diff --git a/Data/Storage/IStorageService.cs b/Data/Storage/IStorageService.cs index 6eb661b..41ed7f3 100644 --- a/Data/Storage/IStorageService.cs +++ b/Data/Storage/IStorageService.cs @@ -39,6 +39,7 @@ public interface IStorageService { public void CreatePasskey(SavedPasskey key); public void GetUsersPasskeys(string userId, out SavedPasskey[] keys); - public void IncrementPasskeySignCount(string userId, byte[] credId); - public void GetUserIdFromCredentialId(byte[] credId, out string? userId); + public void SetPasskeySignCount(byte[] credId, int val); + public void GetUserIdFromPasskeyId(byte[] credId, out string? userId); + public void GetPasskey(byte[] credId, out SavedPasskey? key); } \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlPasskeys.cs b/Data/Storage/MySQL/MySqlPasskeys.cs index 5eb1f3c..ce9df04 100644 --- a/Data/Storage/MySQL/MySqlPasskeys.cs +++ b/Data/Storage/MySQL/MySqlPasskeys.cs @@ -7,9 +7,10 @@ namespace SerbleAPI.Data.Storage.MySQL; public partial class MySqlStorageService { public void CreatePasskey(SavedPasskey key) { using MySqlCommand cmd = new("INSERT INTO serblesite_user_passkeys" + - "(owner_id, credential_id, public_key, sign_count, aa_guid, attes_client_data_json, descriptor_type, descriptor_id, descriptor_transports, attes_format, transports, backup_eligible, backed_up, attes_object, device_public_keys) VALUES" + - "(@owner_id, @credential_id, @public_key, @sign_count, @aa_guid, @attes_client_data_json, @descriptor_type, @descriptor_id, @descriptor_transports, @attes_format, @transports, @backup_eligible, @backed_up, @attes_object, @device_public_keys)", new MySqlConnection(_connectString)); + "(owner_id, name, credential_id, public_key, sign_count, aa_guid, attes_client_data_json, descriptor_type, descriptor_id, descriptor_transports, attes_format, transports, backup_eligible, backed_up, attes_object, device_public_keys) VALUES" + + "(@owner_id, @name, @credential_id, @public_key, @sign_count, @aa_guid, @attes_client_data_json, @descriptor_type, @descriptor_id, @descriptor_transports, @attes_format, @transports, @backup_eligible, @backed_up, @attes_object, @device_public_keys)", new MySqlConnection(_connectString)); cmd.Parameters.AddWithValue("@owner_id", key.OwnerId); + cmd.Parameters.AddWithValue("@name", key.Name); cmd.Parameters.AddWithValue("@credential_id", Convert.ToBase64String(key.CredentialId!)); cmd.Parameters.AddWithValue("@public_key", Convert.ToBase64String(key.PublicKey!)); cmd.Parameters.AddWithValue("@sign_count", key.SignCount); @@ -46,6 +47,7 @@ public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { private static SavedPasskey ReadPasskey(MySqlDataReader reader) { return new SavedPasskey { OwnerId = reader.GetString("owner_id"), + Name = reader.GetString("name"), CredentialId = Convert.FromBase64String(reader.GetString("credential_id")), PublicKey = Convert.FromBase64String(reader.GetString("public_key")), SignCount = reader.GetUInt32("sign_count"), @@ -64,11 +66,22 @@ private static SavedPasskey ReadPasskey(MySqlDataReader reader) { }; } - public void IncrementPasskeySignCount(string userId, byte[] credId) { + public void SetPasskeySignCount(byte[] credId, int val) { throw new NotImplementedException(); } - public void GetUserIdFromCredentialId(byte[] credId, out string? userId) { + public void GetUserIdFromPasskeyId(byte[] credId, out string? userId) { throw new NotImplementedException(); } + + public void GetPasskey(byte[] credId, out SavedPasskey? key) { + using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_user_passkeys WHERE credential_id=@id", + new MySqlParameter("@id", Convert.ToBase64String(credId))); + if (!reader.Read()) { + key = null; + return; + } + + key = ReadPasskey(reader); + } } \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlStorageService.cs b/Data/Storage/MySQL/MySqlStorageService.cs index d69f5bd..c5c4ad1 100644 --- a/Data/Storage/MySQL/MySqlStorageService.cs +++ b/Data/Storage/MySQL/MySqlStorageService.cs @@ -61,6 +61,7 @@ appid VARCHAR(64), "FOREIGN KEY (user) REFERENCES serblesite_users(id))"); SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_passkeys( owner_id VARCHAR(64), + name VARCHAR(255), credential_id TEXT, public_key TEXT, sign_count INT, From bd68ba791b1e6cb0c087e3facad09ab9858887ab Mon Sep 17 00:00:00 2001 From: copokbl Date: Fri, 14 Jun 2024 22:17:15 +1000 Subject: [PATCH 4/7] Finish passkey logic, still needs organisation --- API/v1/Account/AuthController.cs | 35 +++++++++++++++--------------- Data/Storage/FileStorageService.cs | 28 ++++++++++++++---------- Program.cs | 2 ++ 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/API/v1/Account/AuthController.cs b/API/v1/Account/AuthController.cs index 1059c48..4623439 100644 --- a/API/v1/Account/AuthController.cs +++ b/API/v1/Account/AuthController.cs @@ -91,19 +91,21 @@ public async Task PasskeyAuth([FromBody] AuthenticatorAssertionRa } } - [HttpPost] - [Route("/assertionOptions")] + [HttpPost("passkey/assertionOptions")] public ActionResult AssertionOptionsPost([FromForm] string username) { try { - //var existingCredentials = new List(); - - // if (!string.IsNullOrEmpty(username)) { - // // 1. Get user from DB - // var user = DemoStorage.GetUser(username) ?? throw new ArgumentException("Username was not registered"); - // - // // 2. Get registered credentials from database - // existingCredentials = DemoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList(); - // } + List? existingCredentials = []; + + if (!string.IsNullOrEmpty(username)) { // Load user's existing creds, so we can filter for only theirs + Program.StorageService!.GetUserFromName(username, out User? user); + if (user == null) { + throw new Exception("Invalid user"); + } + + // Get registered credentials from database + Program.StorageService.GetUsersPasskeys(user.Id, out SavedPasskey[] keys); + existingCredentials = keys.Select(k => k.Descriptor).ToList()!; + } AuthenticationExtensionsClientInputs exts = new() { Extensions = true, @@ -111,23 +113,22 @@ public ActionResult AssertionOptionsPost([FromForm] string username) { DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() }; - // 3. Create options - UserVerificationRequirement uv = string.IsNullOrEmpty(userVerification) ? UserVerificationRequirement.Discouraged : userVerification.ToEnum(); + // Create options + const UserVerificationRequirement uv = UserVerificationRequirement.Discouraged; // Maybe change? AssertionOptions options = fido.GetAssertionOptions( existingCredentials, uv, exts ); - // 4. Temporarily store options, session/in-memory cache/redis/db + // Temporarily store options, session/in-memory cache/redis/db HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson()); - // 5. Return options to client + // Return options to client return Json(options); } - catch (Exception e) { - return Json(new AssertionOptions { Status = "error", ErrorMessage = FormatException(e) }); + return BadRequest(e.Message); } } diff --git a/Data/Storage/FileStorageService.cs b/Data/Storage/FileStorageService.cs index ff3425e..1f60810 100644 --- a/Data/Storage/FileStorageService.cs +++ b/Data/Storage/FileStorageService.cs @@ -20,6 +20,7 @@ public class FileStorageService : IStorageService { private Dictionary _kv = new(); private List<(string, string)> _ownedProducts = new(); // (userid, productid) private List<(string, string, string)> _userNotes = new(); // (userid, noteid, note) + private List _passkeys = new(); public void Init() { _users = new List(); @@ -28,6 +29,7 @@ public void Init() { _kv = new Dictionary(); _ownedProducts = new List<(string, string)>(); _userNotes = new List<(string, string, string)>(); + _passkeys = new(); // Add dummy data _users.Add(new User { @@ -45,14 +47,15 @@ public void Init() { Logger.Info("Loading data from data.json..."); if (File.Exists("data.json")) { string jsonData = File.ReadAllText("data.json"); - (List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>) data = - JsonConvert.DeserializeObject<(List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>)>(jsonData); + (List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>, List) data = + JsonConvert.DeserializeObject<(List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>, List)>(jsonData); _users = data.Item1; _apps = data.Item2; _authorizations = data.Item3; _kv = data.Item4; _ownedProducts = data.Item5; _userNotes = data.Item6; + _passkeys = data.Item7; Logger.Info("Loaded data from data.json"); } else { Logger.Info("No data.json found, creating new data.json"); @@ -70,7 +73,7 @@ public void Deinit() { string errorText = "Unspecified error"; Logger.Info("Saving data to data.json..."); try { - File.WriteAllText("data.json", JsonConvert.SerializeObject((_users, _apps, _authorizations, _kv, _ownedProducts, _userNotes))); + File.WriteAllText("data.json", JsonConvert.SerializeObject((_users, _apps, _authorizations, _kv, _ownedProducts, _userNotes, _passkeys))); Logger.Info("Saved data to data.json"); } catch (JsonException e) { @@ -224,26 +227,27 @@ public void DeleteUserNote(string userId, string noteId) { } public void CreatePasskey(SavedPasskey key) { - throw new NotImplementedException(); + _passkeys.Add(key); } public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { - throw new NotImplementedException(); + keys = _passkeys.Where(k => k.OwnerId == userId).ToArray(); } public void SetPasskeySignCount(byte[] credId, int val) { - throw new NotImplementedException(); - } - - public void IncrementPasskeySignCount(string userId, byte[] credId) { - throw new NotImplementedException(); + SavedPasskey? key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); + if (key == null) return; + int index = _passkeys.IndexOf(key); + key.SignCount = (uint) val; + _passkeys[index] = key; } public void GetUserIdFromPasskeyId(byte[] credId, out string? userId) { - throw new NotImplementedException(); + SavedPasskey? key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); + userId = key?.OwnerId; } public void GetPasskey(byte[] credId, out SavedPasskey? key) { - throw new NotImplementedException(); + key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); } } diff --git a/Program.cs b/Program.cs index 88b5866..e048ae8 100644 --- a/Program.cs +++ b/Program.cs @@ -216,6 +216,8 @@ private static int Run(string[] args) { builder.Services.AddControllers(); builder.Services.AddSwaggerGen(); builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddDistributedMemoryCache(); + builder.Services.AddMemoryCache(); builder.WebHost.UseUrls(Config["bind_url"]); builder.Services.AddFido2(options => { From 530e57e48c97e42da5b138bf60ff623347832175 Mon Sep 17 00:00:00 2001 From: CoPokBl Date: Thu, 27 Jun 2024 11:20:08 +1000 Subject: [PATCH 5/7] Fix wrong config ref --- Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Program.cs b/Program.cs index e048ae8..abc64d6 100644 --- a/Program.cs +++ b/Program.cs @@ -225,7 +225,7 @@ private static int Run(string[] args) { options.ServerName = "FIDO2 Test"; options.Origins = Config["fido_origins"].Split(';').ToHashSet(); options.TimestampDriftTolerance = 1000 * 60 * 5; - options.ServerIcon = Config["server_icon_url"]; + options.ServerIcon = Config["server_icon"]; }).AddCachedMetadataService(config => { config.AddFidoMetadataRepository(_ => { From d813b3033b392ab4d297d477edf610baf005dcfc Mon Sep 17 00:00:00 2001 From: CoPokBl Date: Fri, 20 Feb 2026 15:57:38 +1100 Subject: [PATCH 6/7] - New db schema - Major rewrite of critial systems - EF migrations - Project organisation --- .gitignore | 16 +- API/ControllerManager.cs | 21 +- API/Redirects/Adam.cs | 14 - API/Redirects/Discord.cs | 14 - API/Redirects/README.md | 3 - API/RedirectsMiddleware.cs | 24 ++ API/SerbleCorsMiddleware.cs | 140 ++++++ API/v1/Account/AccountController.cs | 151 ++++--- API/v1/Account/AccountProductsController.cs | 33 +- API/v1/Account/AuthController.cs | 166 ++++--- API/v1/Account/AuthorizedAppsController.cs | 92 ++-- API/v1/Account/EmailConfirmationController.cs | 12 +- API/v1/Account/MfaController.cs | 97 ++--- API/v1/Account/OAuthAuthController.cs | 13 +- API/v1/Account/OAuthTokenController.cs | 38 +- API/v1/Account/PasskeyController.cs | 161 ++++--- API/v1/Apps/AppController.cs | 168 +++----- API/v1/Payments/CreateCheckoutController.cs | 192 +++------ API/v1/Payments/OrderSuccessController.cs | 9 +- API/v1/Payments/ProductsController.cs | 28 +- API/v1/Payments/StripeWebhookController.cs | 162 +++---- API/v1/People/AdamController.cs | 5 +- API/v1/People/DanController.cs | 5 +- API/v1/People/README.md | 2 +- API/v1/People/TristanController.cs | 5 +- API/v1/RootController.cs | 9 +- API/v1/Services/CheckUser.cs | 9 +- API/v1/Services/RawDataController.cs | 7 - API/v1/Services/ReCaptchaController.cs | 13 +- API/v1/Services/Redirect.cs | 9 +- API/v1/ServicesController.cs | 15 +- API/v1/Vault/NotesController.cs | 90 ++-- Authentication/SerbleAuthenticationHandler.cs | 106 +++++ .../SerbleClaimsPrincipalExtensions.cs | 54 +++ Config/ApiEmailAddresses.cs | 7 + Config/ApiSettings.cs | 9 + Config/EmailSettings.cs | 10 + Config/JwtSettings.cs | 7 + Config/PasskeySettings.cs | 12 + Config/ReCaptchaSettings.cs | 5 + Config/StripeSettings.cs | 7 + Config/TurnstileSettings.cs | 5 + Data/ApiDataSchemas/AccountEditRequest.cs | 15 +- Data/ApiDataSchemas/AntiSpamHeader.cs | 14 + Data/ApiDataSchemas/AuthorizationHeaderApp.cs | 69 --- .../ApiDataSchemas/AuthorizationHeaderUser.cs | 45 -- .../SerbleAuthorizationHeader.cs | 114 +---- Data/Email.cs | 37 +- Data/EmailConfirmationService.cs | 28 -- Data/LocalisationHandler.cs | 6 - Data/ProductManager.cs | 11 +- Data/Raw/RawDataManager.cs | 3 - Data/Schemas/SavedPasskey.cs | 2 +- Data/Schemas/User.cs | 45 +- Data/SerbleUtils.cs | 18 + Data/ServicesStatusService.cs | 10 +- Data/Storage/FileStorageService.cs | 253 ----------- Data/Storage/IStorageService.cs | 45 -- Data/Storage/MySQL/MySqlAuthorizedApps.cs | 35 -- Data/Storage/MySQL/MySqlKV.cs | 24 -- Data/Storage/MySQL/MySqlOAuthApps.cs | 68 --- Data/Storage/MySQL/MySqlPasskeys.cs | 87 ---- Data/Storage/MySQL/MySqlProducts.cs | 40 -- Data/Storage/MySQL/MySqlStorageService.cs | 86 ---- Data/Storage/MySQL/MySqlUsers.cs | 118 ----- Data/Storage/MySQL/MySqlVault.cs | 46 -- Migrations/20260220115153_Initial.Designer.cs | 344 +++++++++++++++ Migrations/20260220115153_Initial.cs | 272 ++++++++++++ Migrations/SerbleDbContextModelSnapshot.cs | 341 +++++++++++++++ Models/DbApp.cs | 28 ++ Models/DbKv.cs | 12 + Models/DbOwnedProduct.cs | 19 + Models/DbUser.cs | 41 ++ Models/DbUserAuthorizedApp.cs | 24 ++ Models/DbUserNote.cs | 19 + Models/DbUserPasskey.cs | 47 ++ Models/SerbleDbContext.cs | 26 ++ Program.cs | 404 ++++++------------ Repositories/IAppRepository.cs | 11 + Repositories/IKvRepository.cs | 6 + Repositories/INoteRepository.cs | 9 + Repositories/IPasskeyRepository.cs | 13 + Repositories/IProductRepository.cs | 7 + Repositories/IUserRepository.cs | 17 + Repositories/Impl/AppRepository.cs | 57 +++ Repositories/Impl/KvRepository.cs | 23 + Repositories/Impl/NoteRepository.cs | 37 ++ Repositories/Impl/PasskeyRepository.cs | 101 +++++ Repositories/Impl/ProductRepository.cs | 27 ++ Repositories/Impl/UserRepository.cs | 114 +++++ SerbleAPI.csproj | 8 +- Services/IAntiSpamService.cs | 9 + Services/IEmailConfirmationService.cs | 7 + Services/IGoogleReCaptchaService.cs | 7 + Services/ITokenService.cs | 26 ++ Services/ITurnstileCaptchaService.cs | 7 + .../Impl/AntiSpamService.cs | 28 +- Services/Impl/EmailConfirmationService.cs | 31 ++ .../Impl/GoogleReCaptchaService.cs | 16 +- .../Impl/TokenService.cs | 157 +++---- .../Impl/TurnstileCaptchaService.cs | 18 +- appsettings.Development.json | 8 - appsettings.json | 47 +- migrate.py | 156 +++++++ 104 files changed, 3180 insertions(+), 2508 deletions(-) delete mode 100644 API/Redirects/Adam.cs delete mode 100644 API/Redirects/Discord.cs delete mode 100644 API/Redirects/README.md create mode 100644 API/RedirectsMiddleware.cs create mode 100644 API/SerbleCorsMiddleware.cs create mode 100644 Authentication/SerbleAuthenticationHandler.cs create mode 100644 Authentication/SerbleClaimsPrincipalExtensions.cs create mode 100644 Config/ApiEmailAddresses.cs create mode 100644 Config/ApiSettings.cs create mode 100644 Config/EmailSettings.cs create mode 100644 Config/JwtSettings.cs create mode 100644 Config/PasskeySettings.cs create mode 100644 Config/ReCaptchaSettings.cs create mode 100644 Config/StripeSettings.cs create mode 100644 Config/TurnstileSettings.cs create mode 100644 Data/ApiDataSchemas/AntiSpamHeader.cs delete mode 100644 Data/ApiDataSchemas/AuthorizationHeaderApp.cs delete mode 100644 Data/ApiDataSchemas/AuthorizationHeaderUser.cs delete mode 100644 Data/EmailConfirmationService.cs delete mode 100644 Data/Storage/FileStorageService.cs delete mode 100644 Data/Storage/IStorageService.cs delete mode 100644 Data/Storage/MySQL/MySqlAuthorizedApps.cs delete mode 100644 Data/Storage/MySQL/MySqlKV.cs delete mode 100644 Data/Storage/MySQL/MySqlOAuthApps.cs delete mode 100644 Data/Storage/MySQL/MySqlPasskeys.cs delete mode 100644 Data/Storage/MySQL/MySqlProducts.cs delete mode 100644 Data/Storage/MySQL/MySqlStorageService.cs delete mode 100644 Data/Storage/MySQL/MySqlUsers.cs delete mode 100644 Data/Storage/MySQL/MySqlVault.cs create mode 100644 Migrations/20260220115153_Initial.Designer.cs create mode 100644 Migrations/20260220115153_Initial.cs create mode 100644 Migrations/SerbleDbContextModelSnapshot.cs create mode 100644 Models/DbApp.cs create mode 100644 Models/DbKv.cs create mode 100644 Models/DbOwnedProduct.cs create mode 100644 Models/DbUser.cs create mode 100644 Models/DbUserAuthorizedApp.cs create mode 100644 Models/DbUserNote.cs create mode 100644 Models/DbUserPasskey.cs create mode 100644 Models/SerbleDbContext.cs create mode 100644 Repositories/IAppRepository.cs create mode 100644 Repositories/IKvRepository.cs create mode 100644 Repositories/INoteRepository.cs create mode 100644 Repositories/IPasskeyRepository.cs create mode 100644 Repositories/IProductRepository.cs create mode 100644 Repositories/IUserRepository.cs create mode 100644 Repositories/Impl/AppRepository.cs create mode 100644 Repositories/Impl/KvRepository.cs create mode 100644 Repositories/Impl/NoteRepository.cs create mode 100644 Repositories/Impl/PasskeyRepository.cs create mode 100644 Repositories/Impl/ProductRepository.cs create mode 100644 Repositories/Impl/UserRepository.cs create mode 100644 Services/IAntiSpamService.cs create mode 100644 Services/IEmailConfirmationService.cs create mode 100644 Services/IGoogleReCaptchaService.cs create mode 100644 Services/ITokenService.cs create mode 100644 Services/ITurnstileCaptchaService.cs rename Data/ApiDataSchemas/AntiSpamProtection.cs => Services/Impl/AntiSpamService.cs (52%) create mode 100644 Services/Impl/EmailConfirmationService.cs rename Data/GoogleReCaptchaHandler.cs => Services/Impl/GoogleReCaptchaService.cs (60%) rename Data/TokenHandler.cs => Services/Impl/TokenService.cs (63%) rename Data/TurnstileCaptchaHandler.cs => Services/Impl/TurnstileCaptchaService.cs (61%) delete mode 100644 appsettings.Development.json create mode 100644 migrate.py diff --git a/.gitignore b/.gitignore index 8b0a651..80ffcd4 100755 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,9 @@ # Common IntelliJ Platform excludes -# User specific -**/.idea/**/workspace.xml -**/.idea/**/tasks.xml -**/.idea/shelf/* -**/.idea/dictionaries -**/.idea/httpRequests/ +# let people change this +appsettings.Development.json -# Sensitive or high-churn files -**/.idea/**/dataSources/ -**/.idea/**/dataSources.ids -**/.idea/**/dataSources.xml -**/.idea/**/dataSources.local.xml -**/.idea/**/sqlDataSources.xml -**/.idea/**/dynamic.xml +.idea/ # Rider # Rider auto-generates .iml files, and contentModel.xml diff --git a/API/ControllerManager.cs b/API/ControllerManager.cs index b5a1d7b..5afb6ae 100644 --- a/API/ControllerManager.cs +++ b/API/ControllerManager.cs @@ -1,5 +1,4 @@ using System.Net; -using GeneralPurposeLib; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Primitives; @@ -7,28 +6,18 @@ namespace SerbleAPI.API; public class ControllerManager : Controller { + private ILogger Logger => HttpContext.RequestServices + .GetRequiredService() + .CreateLogger(GetType()); public override void OnActionExecuting(ActionExecutingContext context) { - - // Add CORS headers to all responses - context.HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", "*"); - context.HttpContext.Response.Headers.Add("Access-Control-Allow-Headers", "*"); - context.HttpContext.Response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH"); - context.HttpContext.Response.Headers.Add("Access-Control-Allow-Credentials", "true"); - - // get ip address IPAddress? ip = Request.HttpContext.Connection.RemoteIpAddress; - - // Somehow it can be null string ipStr = ip == null ? "Unknown IP" : ip.ToString(); base.OnActionExecuting(context); - // Log the users information for debugging purposes - Logger.Debug(context.HttpContext.Request.Headers.TryGetValue("User-Agent", out StringValues header) + Logger.LogDebug(context.HttpContext.Request.Headers.TryGetValue("User-Agent", out StringValues header) ? $"New request from: {ipStr} ({header})" : $"New request from: {ipStr} (Unknown user agent)"); - } - -} \ No newline at end of file +} diff --git a/API/Redirects/Adam.cs b/API/Redirects/Adam.cs deleted file mode 100644 index 2d1ea8c..0000000 --- a/API/Redirects/Adam.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace SerbleAPI.API.Redirects; - -[ApiController] -[Route("adam")] -public class Adam : Controller { - - [HttpGet] - public IActionResult Get() { - return Redirect("https://adamflore.com/"); - } - -} \ No newline at end of file diff --git a/API/Redirects/Discord.cs b/API/Redirects/Discord.cs deleted file mode 100644 index 5835a10..0000000 --- a/API/Redirects/Discord.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace SerbleAPI.API.Redirects; - -[ApiController] -[Route("discord")] -public class Discord : Controller { - - [HttpGet] - public IActionResult Get() { - return Redirect("https://discord.gg/fzvcNhW"); - } - -} \ No newline at end of file diff --git a/API/Redirects/README.md b/API/Redirects/README.md deleted file mode 100644 index 701b1ac..0000000 --- a/API/Redirects/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Redirects -Useful redirects for the API. These are not properly part of the API and are not under /api/. -They are all GET requests from the root of the site. \ No newline at end of file diff --git a/API/RedirectsMiddleware.cs b/API/RedirectsMiddleware.cs new file mode 100644 index 0000000..763d2c1 --- /dev/null +++ b/API/RedirectsMiddleware.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Options; +using SerbleAPI.Config; + +namespace SerbleAPI.API; + +/// +/// Simple redirection middleware that just uses the config +/// to redirect certain paths to their specified URLs. +/// +/// +/// +public class RedirectsMiddleware(RequestDelegate next, IOptions settings) { + + public async Task InvokeAsync(HttpContext context) { + string path = context.Request.Path; + + if (settings.Value.Redirects.TryGetValue(path.Trim('/'), out string? redirect)) { + context.Response.Redirect(redirect); + return; + } + + await next(context); + } +} diff --git a/API/SerbleCorsMiddleware.cs b/API/SerbleCorsMiddleware.cs new file mode 100644 index 0000000..2cf53bc --- /dev/null +++ b/API/SerbleCorsMiddleware.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Options; +using SerbleAPI.Config; + +namespace SerbleAPI.API; + +/// +/// Single middleware that handles both CORS and automatic OPTIONS responses. +/// +/// CORS policy: +/// • Passkey routes (api/v1/auth/passkey/**): reflects the request +/// Origin back only when it is in . +/// Allowed headers: serbleauth, Content-Type, authorization. +/// No origin reflected (= browser-blocked) when the origin is not in the list. +/// • All other routes: Access-Control-Allow-Origin: * (open). +/// All headers and methods allowed. +/// +/// OPTIONS handling: +/// Every OPTIONS request is short-circuited with 200 OK and an Allow header +/// built by inspecting the live MVC action descriptor registry — no explicit +/// [HttpOptions] endpoints needed anywhere in the codebase. +/// +public class SerbleCorsMiddleware( + RequestDelegate next, + IOptions passkeySettings, + IActionDescriptorCollectionProvider descriptors) { + + private const string PasskeyPathPrefix = "/api/v1/auth/passkey"; + private const string AllowedHeaders = "serbleauth, Content-Type, authorization"; + private const string AllowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; + + public Task InvokeAsync(HttpContext context) { + string path = context.Request.Path.Value ?? "/"; + string? origin = context.Request.Headers.Origin; + + bool isPasskeyPath = path.StartsWith(PasskeyPathPrefix, StringComparison.OrdinalIgnoreCase); + + ApplyCorsHeaders(context.Response, origin, isPasskeyPath); + + // Short-circuit OPTIONS — browser CORS preflight and plain method-discovery alike. + if (HttpMethods.IsOptions(context.Request.Method)) { + return HandleOptions(context, path); + } + + return next(context); + } + + // CORS header logic + private void ApplyCorsHeaders(HttpResponse response, string? origin, bool isPasskeyPath) { + if (isPasskeyPath) { + // Reflect the origin only when it is in the passkey allow-list. + if (origin != null && + passkeySettings.Value.AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase)) { + response.Headers.AccessControlAllowOrigin = origin; + response.Headers.AccessControlAllowHeaders = AllowedHeaders; + response.Headers.AccessControlAllowMethods = AllowedMethods; + response.Headers.AccessControlAllowCredentials = "true"; + response.Headers.Vary = "Origin"; + } + // No ACAO header → browser enforces block for non-whitelisted origins. + } + else { + // Open policy for all non-passkey routes. + response.Headers.AccessControlAllowOrigin = "*"; + response.Headers.AccessControlAllowHeaders = "*"; + response.Headers.AccessControlAllowMethods = AllowedMethods; + } + } + + // OPTIONS short-circuit + private Task HandleOptions(HttpContext context, string path) { + HashSet methods = MethodsForPath(path); + + // No registered route matched → fall through so the 404 pipeline runs. + if (methods.Count == 0) { + return next(context); + } + + methods.Add("OPTIONS"); + context.Response.Headers.Allow = string.Join(", ", methods.OrderBy(m => m)); + context.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + } + + /// + /// Walks the MVC route registry and returns every HTTP method registered + /// for any action whose route template matches . + /// Route parameter segments ({id}, {id:int}, etc.) are treated + /// as wildcards so api/v1/app/{appid} matches /api/v1/app/abc. + /// + private HashSet MethodsForPath(string requestPath) { + HashSet methods = new(StringComparer.OrdinalIgnoreCase); + + foreach (ControllerActionDescriptor d in + descriptors.ActionDescriptors.Items.OfType()) { + string? template = d.AttributeRouteInfo?.Template; + if (template == null || !TemplateMatches(template, requestPath)) { + continue; + } + + IEnumerable actionMethods = + d.ActionConstraints?.OfType() + .SelectMany(c => c.HttpMethods) + ?? []; + + foreach (string m in actionMethods) { + methods.Add(m.ToUpperInvariant()); + } + } + + return methods; + } + + private static bool TemplateMatches(string template, string requestPath) { + ReadOnlySpan tSpan = template.TrimStart('/'); + ReadOnlySpan pSpan = requestPath.TrimStart('/'); + + // Drop query string + int q = pSpan.IndexOf('?'); + if (q >= 0) pSpan = pSpan[..q]; + + string[] tParts = tSpan.Length == 0 ? [] : tSpan.ToString().Split('/'); + string[] pParts = pSpan.Length == 0 ? [] : pSpan.ToString().Split('/'); + + if (tParts.Length != pParts.Length) { + return false; + } + + for (int i = 0; i < tParts.Length; i++) { + string t = tParts[i]; + // Route parameters: {id}, {id?}, {id:guid}, etc. + if (t.StartsWith('{') && t.EndsWith('}')) continue; + if (!t.Equals(pParts[i], StringComparison.OrdinalIgnoreCase)) return false; + } + + return true; + } +} diff --git a/API/v1/Account/AccountController.cs b/API/v1/Account/AccountController.cs index 587f55a..e8ed43e 100644 --- a/API/v1/Account/AccountController.cs +++ b/API/v1/Account/AccountController.cs @@ -1,125 +1,124 @@ -using GeneralPurposeLib; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SerbleAPI.Authentication; +using SerbleAPI.Config; using SerbleAPI.Data; using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/account/")] -public class AccountController : ControllerManager { - +[Authorize] +public class AccountController( + ILogger logger, + IOptions emailSettings, + IAntiSpamService antiSpam, + IUserRepository userRepo, + IEmailConfirmationService emailConfirmation) : ControllerManager { + [HttpGet] - public ActionResult Get([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? _, out string? msg, - out User target)) return new SanitisedUser(target, scopes); - return Unauthorized(); + public ActionResult Get() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + return new SanitisedUser(target, HttpContext.User.GetScopeString()); } - + [HttpDelete] - public ActionResult Delete([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.CheckAndGetInfo(out User target, out Dictionary t, null, false, Request)) { - return Unauthorized(); - } + [Authorize(Policy = "UserOnly")] + public ActionResult Delete() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + + userRepo.DeleteUser(target.Id); - // Delete the user's account - Program.StorageService!.DeleteUser(target.Id); - if (!target.VerifiedEmail) return Ok(); - - // Send an email + string body = EmailSchemasService.GetEmailSchema(EmailSchema.AccountDeleted, LocalisationHandler.LanguageOrDefault(target)); body = body.Replace("{name}", target.Username); - Email email = new( - target.Email.ToSingleItemEnumerable().ToArray(), - FromAddress.System, "Serble Account Deletion", - body); - email.SendNonBlocking(); // Don't await so the thread can continue + Email email = new(logger, emailSettings.Value, + target.Email.ToSingleItemEnumerable().ToArray(), + FromAddress.System, "Serble Account Deletion", body); + email.SendNonBlocking(); return Ok(); } [HttpPost] - public async Task> Register([FromBody] RegisterRequestBody requestBody, [FromHeader] AntiSpamProtection antiSpam) { - if (!await antiSpam.Check(HttpContext)) { + [AllowAnonymous] + public async Task> Register([FromBody] RegisterRequestBody requestBody, [FromHeader] AntiSpamHeader antiSpamHeader) { + if (!await antiSpam.Check(antiSpamHeader, HttpContext)) return BadRequest("Anti-spam check failed"); - } - if (requestBody.Password.Length > 256) { + if (requestBody.Password.Length > 256) return BadRequest("Password cannot be longer than 256 characters"); - } - - Program.StorageService!.GetUserFromName(requestBody.Username, out User? existingUser); - if (existingUser != null) { + + if (userRepo.GetUserFromName(requestBody.Username) != null) return Conflict("User already exists"); - } + string passwordSalt = SerbleUtils.RandomString(64); User newUser = new() { - Username = requestBody.Username, + Username = requestBody.Username, PasswordHash = (requestBody.Password + passwordSalt).Sha256Hash(), PasswordSalt = passwordSalt, - PermLevel = 1, - PermString = "0" + PermLevel = 1 }; - Program.StorageService.AddUser(newUser, out User user); - Logger.Debug("User " + user.Username + " created"); - return Ok(new SanitisedUser(user, "1", true)); // Ignore authed apps to stop error + newUser.WithRepos(userRepo); + userRepo.AddUser(newUser, out User user); + logger.LogDebug("User " + user.Username + " created"); + return Ok(new SanitisedUser(user, "1", true)); } [HttpPatch] - public Task> EditAccount([FromHeader] SerbleAuthorizationHeader authorizationHeader, [FromBody] AccountEditRequest[] edits) { - if (!authorizationHeader.CheckAndGetInfo(out User target, out Dictionary t, out SerbleAuthorizationHeaderType type, out string scopes, ScopeHandler.ScopesEnum.ManageAccount, false, Request)) { - return Task.FromResult>(Unauthorized()); - } + [Authorize(Policy = "Scope:ManageAccount")] + public Task> EditAccount([FromBody] AccountEditRequest[] edits) { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Task.FromResult>(Unauthorized()); - if (edits.Any(e => e.Field.ToLower() == "password") && type != SerbleAuthorizationHeaderType.User) { + if (edits.Any(e => e.Field.ToLower() == "password") && !HttpContext.User.IsUser()) return Task.FromResult>(Forbid()); - } + + Dictionary t = LocalisationHandler.GetTranslations( + LocalisationHandler.GetPreferredLanguageOrDefault(Request, target)); + string scopes = HttpContext.User.GetScopeString(); string originalEmail = target.Email; User newUser = target; foreach (AccountEditRequest editRequest in edits) { - if (!editRequest.TryApplyChanges(newUser, out User modUser, out string applyErrorMsg)) { + if (!editRequest.TryApplyChanges(newUser, out User modUser, out string applyErrorMsg, userRepo)) return Task.FromResult>(BadRequest(applyErrorMsg)); - } newUser = modUser; } - - // Check for email change so we can send a confirmation email - Logger.Debug("Email from " + originalEmail + " to " + newUser.Email); - if (newUser.Email != originalEmail && newUser.Email != "") { - // Make sure the new email is not verified + + logger.LogDebug("Email from " + originalEmail + " to " + newUser.Email); + if (newUser.Email != originalEmail && !string.IsNullOrWhiteSpace(newUser.Email)) { newUser.VerifiedEmail = false; - Logger.Debug("Sending email verification"); - EmailConfirmationService.SendConfirmationEmail(newUser); - - // Send email to old email - string body = EmailSchemasService.GetEmailSchema(EmailSchema.EmailChanged, LocalisationHandler.LanguageOrDefault(target)); - body = body.Replace("{name}", target.Username); - body = body.Replace("{new_email}", newUser.Email); - body = body.Replace("{old_email}", originalEmail); - Email email = new( - originalEmail.ToSingleItemEnumerable().ToArray(), - FromAddress.System, t["email-changed-subject"], - body); - email.SendNonBlocking(); // Don't await so the thread can continue + logger.LogDebug("Sending email verification"); + emailConfirmation.SendConfirmationEmail(newUser); + + if (!string.IsNullOrWhiteSpace(originalEmail)) { + logger.LogDebug("Sending email change notification to " + originalEmail); + string body = EmailSchemasService.GetEmailSchema(EmailSchema.EmailChanged, LocalisationHandler.LanguageOrDefault(target)); + body = body.Replace("{name}", target.Username) + .Replace("{new_email}", newUser.Email) + .Replace("{old_email}", originalEmail); + Email email = new(logger, emailSettings.Value, + originalEmail.ToSingleItemEnumerable().ToArray(), + FromAddress.System, t["email-changed-subject"], body); + email.SendNonBlocking(); + } } - - Program.StorageService!.UpdateUser(newUser); + userRepo.UpdateUser(newUser); return Task.FromResult>(new SanitisedUser(target, scopes)); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, DELETE, POST, PATCH, OPTIONS"); - return Ok(); - } - } +// This is here because it is. +// Don't remove it. +// ReSharper disable once UnusedType.Global public class Adam { - public Adam() { - Logger.Error("OH NO U UNLEASHED ADAM"); - throw new Exception("Adam"); - } + public Adam() { throw new Exception("Adam"); } } \ No newline at end of file diff --git a/API/v1/Account/AccountProductsController.cs b/API/v1/Account/AccountProductsController.cs index 7d952fe..f0840d2 100644 --- a/API/v1/Account/AccountProductsController.cs +++ b/API/v1/Account/AccountProductsController.cs @@ -1,32 +1,21 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SerbleAPI.Authentication; using SerbleAPI.Data; -using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; -namespace SerbleAPI.API.v1.Account; +namespace SerbleAPI.API.v1.Account; [Route("api/v1/account/products")] [Controller] -public class AccountProductsController : ControllerManager { - - [HttpGet] - public ActionResult Get([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? authType, out string? _, out User target)) { - return Unauthorized(); - } +[Authorize(Policy = "Scope:PaymentInfo")] +public class AccountProductsController(IUserRepository userRepo, IProductRepository productRepo) : ControllerManager { - ScopeHandler.ScopesEnum[] scopesEnums = ScopeHandler.ScopeStringToEnums(scopes).ToArray(); - if (authType == SerbleAuthorizationHeaderType.App && - !scopesEnums.Contains(ScopeHandler.ScopesEnum.PaymentInfo) && !scopesEnums.Contains(ScopeHandler.ScopesEnum.FullAccess)) { - return Forbid("Insufficient scope"); - } - return Ok(ProductManager.ListOfProductsFromUser(target)); - } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); + [HttpGet] + public ActionResult Get() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + return Ok(ProductManager.ListOfProductsFromUser(target, productRepo)); } - } \ No newline at end of file diff --git a/API/v1/Account/AuthController.cs b/API/v1/Account/AuthController.cs index 4623439..7ba603e 100644 --- a/API/v1/Account/AuthController.cs +++ b/API/v1/Account/AuthController.cs @@ -1,141 +1,131 @@ using System.Text; using Fido2NetLib; using Fido2NetLib.Objects; -using GeneralPurposeLib; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using SerbleAPI.Data; using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; -namespace SerbleAPI.API.v1.Account; +namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/auth")] -public class AuthController(IFido2 fido): ControllerManager { +[AllowAnonymous] +public class AuthController( + IFido2 fido, + ILogger logger, + ITokenService tokens, + IUserRepository userRepo, + IPasskeyRepository passkeyRepo, + IMemoryCache cache) : ControllerManager { - [HttpGet("")] // Keep for backwards compatibility + private static readonly MemoryCacheEntryOptions ChallengeExpiry = + new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); + + [HttpGet("")] [HttpPost("password")] public IActionResult PasswordAuth([FromHeader] BasicAuthorizationHeader authorizationHeader) { - if (authorizationHeader.IsNull()) { - return BadRequest("Authorization header is missing"); - } - if (!authorizationHeader.IsValid()) { - return BadRequest("Authorization header is invalid"); - } - - // Valid header, check credentials + if (authorizationHeader.IsNull()) return BadRequest("Authorization header is missing"); + if (!authorizationHeader.IsValid()) return BadRequest("Authorization header is invalid"); + string username = authorizationHeader.GetUsername(); string password = authorizationHeader.GetPassword(); - if (password.Length > 256) { - return BadRequest("Password cannot be longer than 256 characters"); - } - Program.StorageService!.GetUserFromName(username, out User? user); - if (user == null) { - return Unauthorized(); - } - if (!user.CheckPassword(password)) { - return Unauthorized(); - } + if (password.Length > 256) return BadRequest("Password cannot be longer than 256 characters"); + + User? user = userRepo.GetUserFromName(username); + if (user == null) return Unauthorized(); + if (!user.CheckPassword(password)) return Unauthorized(); if (user.TotpEnabled) { - // 2FA is enabled, return a first stage login token - string mfaToken = TokenHandler.GenerateFirstStepLoginToken(user.Id); - return Ok(new { - mfa_token = mfaToken, - success = true, - mfa_required = true - }); + string mfaToken = tokens.GenerateFirstStepLoginToken(user.Id); + return Ok(new { mfa_token = mfaToken, success = true, mfa_required = true }); } - - // Valid credentials, return token - string token = TokenHandler.GenerateLoginToken(user.Id); - return Ok(new { - token, - success = true, - mfa_required = false - }); + + string token = tokens.GenerateLoginToken(user.Id); + return Ok(new { token, success = true, mfa_required = false }); } - [HttpPost("passkey/assertion")] // Make Assertion - public async Task PasskeyAuth([FromBody] AuthenticatorAssertionRawResponse clientResponse, + [HttpPost("passkey/assertion")] + public async Task PasskeyAuth( + [FromBody] AuthenticatorAssertionRawResponse clientResponse, + [FromQuery] string challengeId, CancellationToken cancellationToken) { try { - // Get the assertion options we sent the client - string? jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions"); + string cacheKey = $"fido2:assertion:{challengeId}"; + if (!cache.TryGetValue(cacheKey, out string? jsonOptions) || jsonOptions == null) + return BadRequest("Challenge not found or expired. Request new assertion options."); + + // Consume the challenge — one-time use only. + cache.Remove(cacheKey); + AssertionOptions? options = AssertionOptions.FromJson(jsonOptions); - // Get registered credential from database - Program.StorageService!.GetPasskey(clientResponse.Id, out SavedPasskey? creds); - if (creds == null) { - return BadRequest("Unknown passkey"); - } + SavedPasskey? creds = passkeyRepo.GetPasskey(clientResponse.Id); + if (creds == null) return BadRequest("Unknown passkey"); - // Create callback to check if the user handle owns the credentialId - IsUserHandleOwnerOfCredentialIdAsync callback = static (args, _) => { - Program.StorageService.GetUsersPasskeys(Encoding.UTF8.GetString(args.UserHandle), out SavedPasskey[] storedCreds); - return Task.FromResult(storedCreds.Any(c => c.Descriptor!.Id.SequenceEqual(args.CredentialId))); // TODO: Should this be c.CredentialId + IsUserHandleOwnerOfCredentialIdAsync callback = (args, _) => { + SavedPasskey[] storedCreds = passkeyRepo.GetUsersPasskeys(Encoding.UTF8.GetString(args.UserHandle)); + return Task.FromResult(storedCreds.Any(c => c.Descriptor!.Id.SequenceEqual(args.CredentialId))); }; - // Make the assertion - VerifyAssertionResult res = await fido.MakeAssertionAsync(clientResponse, options, creds.PublicKey!, creds.DevicePublicKeys!, creds.SignCount, callback, cancellationToken: cancellationToken); - Program.StorageService.SetPasskeySignCount(res.CredentialId, (int) res.SignCount); + VerifyAssertionResult res = await fido.MakeAssertionAsync( + clientResponse, options, + creds.PublicKey!, creds.DevicePublicKeys ?? [], creds.SignCount, + callback, cancellationToken: cancellationToken); + + passkeyRepo.SetPasskeySignCount(res.CredentialId, (int)res.SignCount); if (res.DevicePublicKey is not null) { - creds.DevicePublicKeys = creds.DevicePublicKeys!.Append(res.DevicePublicKey).ToArray(); + byte[][] updatedKeys = (creds.DevicePublicKeys ?? []).Append(res.DevicePublicKey).ToArray(); + passkeyRepo.UpdatePasskeyDevicePublicKeys(res.CredentialId, updatedKeys); } - return Json(res); + string token = tokens.GenerateLoginToken(creds.OwnerId!); + return Ok(new { token, success = true }); } catch (Exception e) { - return BadRequest("Failed"); + logger.LogDebug("Passkey assertion failed: " + e.Message); + return BadRequest("Passkey assertion failed: " + e.Message); } } - + + [HttpGet("passkey/assertionOptions")] + public ActionResult AssertionOptionsGet() => AssertionOptionsPost(null); + [HttpPost("passkey/assertionOptions")] - public ActionResult AssertionOptionsPost([FromForm] string username) { + public ActionResult AssertionOptionsPost([FromForm] string? username) { try { - List? existingCredentials = []; - - if (!string.IsNullOrEmpty(username)) { // Load user's existing creds, so we can filter for only theirs - Program.StorageService!.GetUserFromName(username, out User? user); - if (user == null) { - throw new Exception("Invalid user"); - } - - // Get registered credentials from database - Program.StorageService.GetUsersPasskeys(user.Id, out SavedPasskey[] keys); - existingCredentials = keys.Select(k => k.Descriptor).ToList()!; + List existingCredentials = []; + + if (!string.IsNullOrEmpty(username)) { + User? user = userRepo.GetUserFromName(username); + if (user == null) throw new Exception("Invalid user"); + existingCredentials = passkeyRepo.GetUsersPasskeys(user.Id) + .Select(k => k.Descriptor!) + .ToList(); } AuthenticationExtensionsClientInputs exts = new() { - Extensions = true, + Extensions = true, UserVerificationMethod = true, - DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() + DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() }; - // Create options - const UserVerificationRequirement uv = UserVerificationRequirement.Discouraged; // Maybe change? AssertionOptions options = fido.GetAssertionOptions( - existingCredentials, - uv, - exts - ); + existingCredentials, UserVerificationRequirement.Required, exts); - // Temporarily store options, session/in-memory cache/redis/db - HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson()); + // Store assertion challenge in cache, return ID to client. + string challengeId = Guid.NewGuid().ToString("N"); + cache.Set($"fido2:assertion:{challengeId}", options.ToJson(), ChallengeExpiry); - // Return options to client - return Json(options); + return Json(new { challengeId, options }); } catch (Exception e) { return BadRequest(e.Message); } } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Account/AuthorizedAppsController.cs b/API/v1/Account/AuthorizedAppsController.cs index 9320a6f..ac91af8 100644 --- a/API/v1/Account/AuthorizedAppsController.cs +++ b/API/v1/Account/AuthorizedAppsController.cs @@ -1,89 +1,53 @@ -using GeneralPurposeLib; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SerbleAPI.Authentication; using SerbleAPI.Data; -using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/account/authorizedApps")] -public class AuthorizedAppsController : ControllerManager { +[Authorize] +public class AuthorizedAppsController( + IUserRepository userRepo, + IAppRepository appRepo, + ITokenService tokens) : ControllerManager { [HttpGet] - public ActionResult GetAll([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? _, out string? msg, out User target)) { - Logger.Debug("Check failed: " + msg); - return Unauthorized(); - } - - if (!scopes.SerbleHasScope(ScopeHandler.ScopesEnum.ManageAccount)) { - return Forbid("Scope ManageAccount is required."); - } - + [Authorize(Policy = "Scope:ManageAccount")] + public ActionResult GetAll() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); target.ObtainAuthorizedApps(); return target.AuthorizedApps; } [HttpPost] - public IActionResult AuthorizeApp([FromHeader] SerbleAuthorizationHeader authorizationHeader, [FromBody] AuthorizedApp app) { - if (!authorizationHeader.Check(out string _, out SerbleAuthorizationHeaderType? authType, out string msg, out User user)) { - Logger.Debug("Check failed: " + msg); - return Unauthorized(); - } + [Authorize(Policy = "UserOnly")] + public IActionResult AuthorizeApp([FromBody] AuthorizedApp app) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); - if (authType != SerbleAuthorizationHeaderType.User) { - // Not a user - Logger.Debug("Not a user"); - return Forbid("Only users can access this endpoint"); - } + OAuthApp? appObj = appRepo.GetOAuthApp(app.AppId); + if (appObj == null) return BadRequest("Invalid app"); - Program.StorageService!.GetOAuthApp(app.AppId, out OAuthApp? appObj); - if (appObj == null) { - return BadRequest("Invalid app"); - } - AuthorizedApp validatedApp = new(app.AppId, new Scopes(app.Scopes).ScopesString); - user.AuthorizeApp(validatedApp); - return Ok(TokenHandler.GenerateAuthorizationToken(user.Id, app.AppId, app.Scopes)); + user.AuthorizeApp(new AuthorizedApp(app.AppId, new Scopes(app.Scopes).ScopesString)); + return Ok(tokens.GenerateAuthorizationToken(user.Id, app.AppId, app.Scopes)); } [HttpDelete("{appId}")] - public ActionResult DeAuthorizeApp([FromHeader] SerbleAuthorizationHeader authorizationHeader, string appId) { - if (!authorizationHeader.Check(out string _, out SerbleAuthorizationHeaderType? authType, out string msg, out User user)) { - Logger.Debug("Check failed: " + msg); - return Unauthorized(); - } + [Authorize(Policy = "UserOnly")] + public ActionResult DeAuthorizeApp(string appId) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); - if (authType != SerbleAuthorizationHeaderType.User) { - // Not a user - Logger.Debug("Not a user"); - return Forbid("Only users can access this endpoint"); - } - - Program.StorageService!.GetOAuthApp(appId, out OAuthApp? appObj); - if (appObj == null) { - return BadRequest("Invalid app"); - } - if (user.AuthorizedApps.All(sortApp => sortApp.AppId != appObj.Id)) { - // The app is not authorized + if (user.AuthorizedApps.All(a => a.AppId != appId)) return BadRequest("App is not authorized"); - } - - user.AuthorizedApps = user.AuthorizedApps.Where(sortedApp => sortedApp.AppId != appObj.Id).ToArray(); - user.UpdateAuthorizedApps(); - return Ok(); - } - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, OPTIONS"); + userRepo.DeleteAuthorizedApp(user.Id, appId); return Ok(); } - - [HttpOptions("{appId}")] - public ActionResult OptionsApp() { - HttpContext.Response.Headers.Add("Allow", "DELETE, OPTIONS"); - return Ok(); - } - -} +} \ No newline at end of file diff --git a/API/v1/Account/EmailConfirmationController.cs b/API/v1/Account/EmailConfirmationController.cs index 264c150..fe22118 100644 --- a/API/v1/Account/EmailConfirmationController.cs +++ b/API/v1/Account/EmailConfirmationController.cs @@ -1,23 +1,25 @@ using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; +using Microsoft.Extensions.Options; +using SerbleAPI.Config; using SerbleAPI.Data.Schemas; +using SerbleAPI.Services; namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/emailconfirm")] -public class EmailConfirmationController : ControllerManager { +public class EmailConfirmationController(IOptions apiSettings, ITokenService tokens) : ControllerManager { [HttpGet] public ActionResult Confirm([FromQuery] string token, [FromQuery] string? redirect = null, [FromQuery] string? failureRedirect = null) { - if (!TokenHandler.ValidateEmailConfirmationToken(token, out User? user, out string email) || user.Email != email || user.VerifiedEmail) { - return Redirect(redirect ?? $"{Program.Config!["website_url"]}/emailconfirm/error"); + if (!tokens.ValidateEmailConfirmationToken(token, out User user, out string email) || user.Email != email || user.VerifiedEmail) { + return Redirect(redirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/error"); } user.VerifiedEmail = true; user.RegisterChanges(); - return Redirect(failureRedirect ?? $"{Program.Config!["website_url"]}/emailconfirm/success"); + return Redirect(failureRedirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/success"); } } \ No newline at end of file diff --git a/API/v1/Account/MfaController.cs b/API/v1/Account/MfaController.cs index 00a4f9c..7a665fe 100644 --- a/API/v1/Account/MfaController.cs +++ b/API/v1/Account/MfaController.cs @@ -1,89 +1,48 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; +using SerbleAPI.Authentication; using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; -namespace SerbleAPI.API.v1.Account; +namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/account/mfa")] -public class MfaController : ControllerManager { +public class MfaController(ITokenService tokens, IUserRepository userRepo) : ControllerManager { + // Second step of the MFA login flow — no session token yet, only the first-step token from body [HttpPost] + [AllowAnonymous] public IActionResult Authenticate([FromBody] MfaAuthBody body) { - if (body.LoginToken == null) { + if (body.LoginToken == null) return Unauthorized("Login token is missing"); - } - if (!TokenHandler.ValidateFirstStepLoginToken(body.LoginToken, out User user)) { + if (!tokens.ValidateFirstStepLoginToken(body.LoginToken, out User user)) return Unauthorized("Invalid login token"); - } - - // Valid token, check TOTP code - if (!user.ValidateTotp(body.TotpCode)) { + if (!user.ValidateTotp(body.TotpCode)) return Unauthorized("Invalid TOTP code"); - } - - // Valid TOTP code, return token - string token = TokenHandler.GenerateLoginToken(user.Id); - return Ok(new { - token, - success = true - }); + + string token = tokens.GenerateLoginToken(user.Id); + return Ok(new { token, success = true }); } - - [HttpPost] - [Route("totp")] - public IActionResult CheckTotp([FromHeader] SerbleAuthorizationHeader auth, [FromBody] MfaAuthBody body) { - if (!auth.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.ManageAccount)) { - return Unauthorized(); - } - - // Valid token, check TOTP code - if (!user.ValidateTotp(body.TotpCode)) { - return Ok(new { - success = true, - valid = false - }); - } - - return Ok(new { - success = true, - valid = true - }); + + [HttpPost("totp")] + [Authorize(Policy = "Scope:ManageAccount")] + public IActionResult CheckTotp([FromBody] MfaAuthBody body) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + bool valid = user.ValidateTotp(body.TotpCode); + return Ok(new { success = true, valid }); } - + [HttpGet("totp/qrcode")] - public IActionResult GetTotpQrCode([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? authType, out string? msg, - out User target)) return Unauthorized(msg); - if (authType != SerbleAuthorizationHeaderType.User) { - return Unauthorized("Authorization header must be a user"); - } - - // Valid user, generate a TOTP QR code + [Authorize(Policy = "UserOnly")] + public IActionResult GetTotpQrCode() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); byte[]? qrCode = target.GetTotpQrCode(); - if (qrCode == null) { - return BadRequest("TOTP is not enabled."); - } + if (qrCode == null) return BadRequest("TOTP is not enabled."); return File(qrCode, "image/png"); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - - [HttpOptions("totp/qrcode")] - public ActionResult OptionsTotpQrCode() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - - [HttpOptions("totp")] - public ActionResult OptionsCheckTotp() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Account/OAuthAuthController.cs b/API/v1/Account/OAuthAuthController.cs index 1dd8113..b80da75 100644 --- a/API/v1/Account/OAuthAuthController.cs +++ b/API/v1/Account/OAuthAuthController.cs @@ -1,20 +1,15 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SerbleAPI.Config; namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/oauth/auth")] -public class OAuthAuthController : ControllerManager { +public class OAuthAuthController(IOptions apiSettings) : ControllerManager { [HttpGet] public ActionResult Get() { - return Redirect(Program.Config!["website_url"] + "/oauth/authorize"); + return Redirect(apiSettings.Value.WebsiteUrl + "/oauth/authorize"); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Account/OAuthTokenController.cs b/API/v1/Account/OAuthTokenController.cs index 8d60ffa..398da3c 100644 --- a/API/v1/Account/OAuthTokenController.cs +++ b/API/v1/Account/OAuthTokenController.cs @@ -1,8 +1,8 @@ -using GeneralPurposeLib; using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; namespace SerbleAPI.API.v1.Account; @@ -10,7 +10,10 @@ namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/oauth/token")] -public class OAuthTokenController : ControllerManager { +public class OAuthTokenController( + ILogger logger, + ITokenService tokens, + IAppRepository appRepo) : ControllerManager { [HttpPost("refresh")] public ActionResult RequestTokens( @@ -18,11 +21,11 @@ public ActionResult RequestTokens( [FromQuery] string client_id, [FromQuery] string client_secret, [FromQuery] string grant_type) { - Logger.Debug("Validating oauth code: " + code); - if (!TokenHandler.ValidateAuthorizationToken(code, client_id, out User? user, out string scope, out string reason)) { + logger.LogDebug("Validating oauth code: " + code); + if (!tokens.ValidateAuthorizationToken(code, client_id, out User? user, out string scope, out string reason)) { return BadRequest("Invalid authorization code: " + reason); } - Program.StorageService!.GetOAuthApp(client_id, out OAuthApp? app); + OAuthApp? app = appRepo.GetOAuthApp(client_id); if (app == null) { return BadRequest("Invalid client_id"); } @@ -35,8 +38,8 @@ public ActionResult RequestTokens( return Ok(new AccessTokenResponse { ExpiresIn = 87600, - AccessToken = TokenHandler.GenerateAccessToken(user!.Id, scope), - RefreshToken = TokenHandler.GenerateRefreshToken(user.Id, client_id, scope), + AccessToken = tokens.GenerateAccessToken(user!.Id, scope), + RefreshToken = tokens.GenerateRefreshToken(user.Id, client_id, scope), TokenType = "bearer" }); } @@ -47,10 +50,10 @@ public ActionResult RequestAccess( [FromQuery] string client_id, [FromQuery] string client_secret, [FromQuery] string grant_type) { - if (!TokenHandler.ValidateRefreshToken(refresh_token, client_id, out User? user, out string scope)) { + if (!tokens.ValidateRefreshToken(refresh_token, client_id, out User? user, out string scope)) { return BadRequest("Invalid authorization code"); } - Program.StorageService!.GetOAuthApp(client_id, out OAuthApp? app); + OAuthApp? app = appRepo.GetOAuthApp(client_id); if (app == null) { return BadRequest("Invalid client_id"); } @@ -66,22 +69,9 @@ public ActionResult RequestAccess( return Ok(new AccessTokenResponse { ExpiresIn = 1, - AccessToken = TokenHandler.GenerateAccessToken(user.Id, scope), + AccessToken = tokens.GenerateAccessToken(user.Id, scope), RefreshToken = refresh_token, TokenType = "bearer" }); } - - [HttpOptions("access")] - public ActionResult OptionsAcc() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - - [HttpOptions("refresh")] - public ActionResult OptionsRef() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Account/PasskeyController.cs b/API/v1/Account/PasskeyController.cs index 15399f7..dc55a47 100644 --- a/API/v1/Account/PasskeyController.cs +++ b/API/v1/Account/PasskeyController.cs @@ -1,107 +1,146 @@ using System.Text; using Fido2NetLib; using Fido2NetLib.Objects; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; -using SerbleAPI.Data.ApiDataSchemas; +using Microsoft.Extensions.Caching.Memory; +using SerbleAPI.Authentication; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; namespace SerbleAPI.API.v1.Account; [ApiController] [Route("api/v1/auth/passkey")] -public class PasskeyController(IFido2 fido) : ControllerManager { - - [HttpPost("create")] - public Task PasskeyAuth([FromHeader] SerbleAuthorizationHeader auth) { - throw new NotImplementedException(); // Keep this func? +public class PasskeyController(IFido2 fido, IUserRepository userRepo, IPasskeyRepository passkeyRepo, IMemoryCache cache) : ControllerManager { + + // Challenge entries expire after 5 minutes — enough time to complete the + // browser interaction without leaving stale data in memory indefinitely. + private static readonly MemoryCacheEntryOptions ChallengeExpiry = + new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); + + [HttpGet("list")] + [Authorize(Policy = "UserOnly")] + public IActionResult ListPasskeys() { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + SavedPasskey[] keys = passkeyRepo.GetUsersPasskeys(user.Id); + return Json(keys.Select(k => new { + name = k.Name, + credentialId = Convert.ToBase64String(k.CredentialId!), + isBackupEligible = k.IsBackupEligible, + isBackedUp = k.IsBackedUp + })); + } + + [HttpDelete("delete/{name}")] + [Authorize(Policy = "UserOnly")] + public IActionResult DeletePasskey(string name) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + SavedPasskey[] keys = passkeyRepo.GetUsersPasskeys(user.Id); + SavedPasskey? target = keys.FirstOrDefault(k => k.Name == name); + if (target == null) return NotFound("Passkey not found"); + passkeyRepo.DeletePasskey(target.CredentialId!); + return Ok(new { success = true }); } [HttpPost("credentialoptions")] + [Authorize(Policy = "UserOnly")] public IActionResult MakeCredentialOptions( - [FromHeader] SerbleAuthorizationHeader auth, - [FromForm] string attType, - [FromForm] string authType, - [FromForm] string residentKey, - [FromForm] string userVerification) { - if (!auth.CheckAndGetInfo(out User? user, - out _, - ScopeHandler.ScopesEnum.FullAccess, - false)) { - return Unauthorized(); - } - - // 3. Create options + [FromForm] string attType, + [FromForm] string? authType, + [FromForm] string? userVerification) { + + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + AuthenticatorSelection authenticatorSelection = new() { - ResidentKey = residentKey.ToEnum(), - UserVerification = userVerification.ToEnum() + ResidentKey = ResidentKeyRequirement.Required, + UserVerification = UserVerificationRequirement.Required }; - - if (!string.IsNullOrEmpty(authType)) { + if (!string.IsNullOrEmpty(authType)) authenticatorSelection.AuthenticatorAttachment = authType.ToEnum(); - } AuthenticationExtensionsClientInputs exts = new() { - Extensions = true, + Extensions = true, UserVerificationMethod = true, - DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() { Attestation = attType }, - CredProps = true + DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs { Attestation = attType }, + CredProps = true }; Fido2User fidoUser = new() { - Name = user.Username, + Name = user.Username, DisplayName = user.Username, - Id = Encoding.UTF8.GetBytes(user.Id) + Id = Encoding.UTF8.GetBytes(user.Id) }; - IReadOnlyList excludeCreds = []; + IReadOnlyList excludeCreds = passkeyRepo + .GetUsersPasskeys(user.Id) + .Select(k => k.Descriptor!) + .Where(d => d != null!) + .ToList(); - CredentialCreateOptions options = fido.RequestNewCredential(fidoUser, excludeCreds, authenticatorSelection, attType.ToEnum(), exts); + CredentialCreateOptions options = fido.RequestNewCredential( + fidoUser, excludeCreds, authenticatorSelection, + attType.ToEnum(), exts); - // 4. Temporarily store options, session/in-memory cache/redis/db - HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); + // Store the challenge in the in-memory cache under a random ID and + // return that ID to the client. The client sends it back as a query + // parameter on the follow-up POST /credential request. This avoids + // any reliance on session cookies, which are unreliable across origins. + string challengeId = Guid.NewGuid().ToString("N"); + cache.Set($"fido2:attestation:{challengeId}", options.ToJson(), ChallengeExpiry); - // 5. return options to client - return Json(options); + return Json(new { challengeId, options }); } [HttpPost("credential")] - public async Task MakeCredentialOptions([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) { + [AllowAnonymous] + public async Task MakeCredential( + [FromBody] AuthenticatorAttestationRawResponse attestationResponse, + [FromQuery] string challengeId, + CancellationToken cancellationToken) { try { - // 1. get the options we sent the client - string? jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions"); + string cacheKey = $"fido2:attestation:{challengeId}"; + if (!cache.TryGetValue(cacheKey, out string? jsonOptions) || jsonOptions == null) + return BadRequest("Challenge not found or expired. Request new credential options."); + + // Consume the challenge — one-time use only. + cache.Remove(cacheKey); + CredentialCreateOptions? options = CredentialCreateOptions.FromJson(jsonOptions); - // 2. Create callback so that lib can verify credential id is unique to this user - IsCredentialIdUniqueToUserAsyncDelegate callback = static (args, _) => { - Program.StorageService!.GetUserIdFromPasskeyId(args.CredentialId, out string? userId); + IsCredentialIdUniqueToUserAsyncDelegate callback = (args, _) => { + string? userId = passkeyRepo.GetUserIdFromPasskeyId(args.CredentialId); return Task.FromResult(userId == null); }; - // 2. Verify and make the credentials - MakeNewCredentialResult success = await fido.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken); + MakeNewCredentialResult success = await fido.MakeNewCredentialAsync( + attestationResponse, options, callback, cancellationToken: cancellationToken); - // 3. Store the credentials in db string userId = Encoding.UTF8.GetString(success.Result!.User.Id); SavedPasskey cred = new() { - OwnerId = userId, - Name = "Passkey " + Guid.NewGuid(), // Give it a random now - CredentialId = success.Result!.Id, - PublicKey = success.Result.PublicKey, - AaGuid = success.Result.AaGuid, + OwnerId = userId, + Name = "Passkey " + Guid.NewGuid(), + CredentialId = success.Result!.Id, + PublicKey = success.Result.PublicKey, + AaGuid = success.Result.AaGuid, AttestationClientDataJson = success.Result.AttestationClientDataJson, - Descriptor = new PublicKeyCredentialDescriptor(success.Result.Id), - SignCount = success.Result.SignCount, - AttestationFormat = success.Result.AttestationFormat, - Transports = success.Result.Transports, - IsBackupEligible = success.Result.IsBackupEligible, - IsBackedUp = success.Result.IsBackedUp, - AttestationObject = success.Result.AttestationObject, - DevicePublicKeys = [success.Result.DevicePublicKey] + Descriptor = new PublicKeyCredentialDescriptor( + PublicKeyCredentialType.PublicKey, success.Result.Id, success.Result.Transports), + SignCount = success.Result.SignCount, + AttestationFormat = success.Result.AttestationFormat, + Transports = success.Result.Transports, + IsBackupEligible = success.Result.IsBackupEligible, + IsBackedUp = success.Result.IsBackedUp, + AttestationObject = success.Result.AttestationObject, + DevicePublicKeys = success.Result.DevicePublicKey != null + ? [success.Result.DevicePublicKey] : [] }; - - Program.StorageService!.CreatePasskey(cred); - return Json(success); + + passkeyRepo.CreatePasskey(cred); + return Json(new { success = true, credentialId = Convert.ToBase64String(cred.CredentialId!) }); } catch (Exception e) { return BadRequest("Failed to create credentials: " + e.Message); diff --git a/API/v1/Apps/AppController.cs b/API/v1/Apps/AppController.cs index 08dcb3e..a1d2b3b 100644 --- a/API/v1/Apps/AppController.cs +++ b/API/v1/Apps/AppController.cs @@ -1,144 +1,86 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -using SerbleAPI.Data; +using SerbleAPI.Authentication; using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; -namespace SerbleAPI.API.v1.Apps; +namespace SerbleAPI.API.v1.Apps; [ApiController] [Route("api/v1/app/")] -public class AppController : ControllerManager { - +public class AppController(IAppRepository appRepo, IUserRepository userRepo) : ControllerManager { + + // Public endpoint — no auth required [HttpGet("{appid}/public")] + [AllowAnonymous] public IActionResult GetPublicInfo(string appid) { - Program.StorageService!.GetOAuthApp(appid, out OAuthApp? app); - if (app == null) { - return NotFound(); - } - string jsonObj = JsonConvert.SerializeObject(new SanitisedOAuthApp(app)); - return Ok(jsonObj); + OAuthApp? app = appRepo.GetOAuthApp(appid); + if (app == null) return NotFound(); + return Ok(JsonConvert.SerializeObject(new SanitisedOAuthApp(app))); } - + [HttpGet("{appid}")] - public IActionResult GetInfo(string appid, [FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? authType, out string? msg, out User target)) { - return BadRequest(msg); - } - - if (!scopes.SerbleHasScope(ScopeHandler.ScopesEnum.ManageApps)) { - return Forbid("Scope ManageApps is required."); - } - - Program.StorageService!.GetOAuthApp(appid, out OAuthApp? app); - if (app == null) { - return NotFound(); - } - - if (target.Id != app.OwnerId) { - return BadRequest("User does not own app"); - } - - string jsonObj = JsonConvert.SerializeObject(app); - return Ok(jsonObj); + [Authorize(Policy = "Scope:ManageApps")] + public IActionResult GetInfo(string appid) { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + OAuthApp? app = appRepo.GetOAuthApp(appid); + if (app == null) return NotFound(); + if (target.Id != app.OwnerId) return BadRequest("User does not own app"); + return Ok(JsonConvert.SerializeObject(app)); } - - [HttpGet] - public IActionResult GetAll([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? authType, out string? msg, out User target)) { - return Unauthorized(msg); - } - - if (!scopes.SerbleHasScope(ScopeHandler.ScopesEnum.ManageApps)) { - return Forbid("Scope ManageApps is required."); - } - - Program.StorageService!.GetOAuthAppsFromUser(target.Id, out OAuthApp[] apps); - string jsonObj = JsonConvert.SerializeObject(apps); - return Ok(jsonObj); + [HttpGet] + [Authorize(Policy = "Scope:ManageApps")] + public IActionResult GetAll() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + OAuthApp[] apps = appRepo.GetOAuthAppsFromUser(target.Id); + return Ok(JsonConvert.SerializeObject(apps)); } - - [HttpDelete("{appid}")] - public IActionResult Delete(string appid, [FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? authType, out string? msg, out User target)) { - return Unauthorized(msg); - } - - if (!scopes.SerbleHasScope(ScopeHandler.ScopesEnum.ManageApps)) { - return Forbid("Scope ManageApps is required."); - } - Program.StorageService!.GetOAuthApp(appid, out OAuthApp? app); - if (app == null) { - return NotFound(); - } - - if (target.Id != app.OwnerId) { - return Forbid("User does not own app"); - } - - Program.StorageService.DeleteOAuthApp(appid); + [HttpDelete("{appid}")] + [Authorize(Policy = "Scope:ManageApps")] + public IActionResult Delete(string appid) { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + OAuthApp? app = appRepo.GetOAuthApp(appid); + if (app == null) return NotFound(); + if (target.Id != app.OwnerId) return Forbid("User does not own app"); + appRepo.DeleteOAuthApp(appid); return Ok(); } - - [HttpPost] - public IActionResult CreateApp([FromHeader] SerbleAuthorizationHeader authorizationHeader, [FromBody] NewOAuthApp app) { - if (!authorizationHeader.Check(out string? scopes, out SerbleAuthorizationHeaderType? _, out string? msg, out User target)) { - return Unauthorized(msg); - } - - if (!scopes.SerbleHasScope(ScopeHandler.ScopesEnum.ManageApps)) { - return Forbid("Scope ManageApps is required."); - } - Program.StorageService!.AddOAuthApp(new OAuthApp(target.Id) {Description = app.Description, Name = app.Name, RedirectUri = app.RedirectUri}); + [HttpPost] + [Authorize(Policy = "Scope:ManageApps")] + public IActionResult CreateApp([FromBody] NewOAuthApp app) { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + appRepo.AddOAuthApp(new OAuthApp(target.Id) { + Description = app.Description, + Name = app.Name, + RedirectUri = app.RedirectUri + }); return Ok(); } - + [HttpPatch("{appid}")] - public ActionResult EditApp([FromHeader] SerbleAuthorizationHeader authorizationHeader, [FromBody] AppEditRequest[] edits, string appid) { - if (!authorizationHeader.Check(out string scope, out SerbleAuthorizationHeaderType? _, out string? msg, out User user)) { - return Unauthorized(); - } + [Authorize(Policy = "Scope:AppsControl")] + public ActionResult EditApp([FromBody] AppEditRequest[] edits, string appid) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + OAuthApp? target = appRepo.GetOAuthApp(appid); + if (target == null) return NotFound(); - if (!scope.SerbleHasScope(ScopeHandler.ScopesEnum.AppsControl)) { - return Forbid("Scope AppsControl is required."); - } - - Program.StorageService!.GetOAuthApp(appid, out OAuthApp? target); - if (target == null) { - return NotFound(); - } - OAuthApp newApp = target; foreach (AppEditRequest editRequest in edits) { - if (!editRequest.TryApplyChanges(newApp, out OAuthApp modApp, out string applyErrorMsg)) { + if (!editRequest.TryApplyChanges(newApp, out OAuthApp modApp, out string applyErrorMsg)) return BadRequest(applyErrorMsg); - } newApp = modApp; } - - Program.StorageService.UpdateOAuthApp(newApp); + appRepo.UpdateOAuthApp(newApp); return newApp; } - - [HttpOptions("{appid}/public")] - public ActionResult OptionsPublic() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - - [HttpOptions("{appid}")] - public ActionResult OptionsAppId() { - HttpContext.Response.Headers.Add("Allow", "GET, DELETE, PATCH, OPTIONS"); - return Ok(); - } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Payments/CreateCheckoutController.cs b/API/v1/Payments/CreateCheckoutController.cs index 57e6910..d2f493c 100644 --- a/API/v1/Payments/CreateCheckoutController.cs +++ b/API/v1/Payments/CreateCheckoutController.cs @@ -1,8 +1,13 @@ using System.Text.Json; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SerbleAPI.Authentication; +using SerbleAPI.Config; using SerbleAPI.Data; -using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; +using SerbleAPI.Services; using Stripe; using Stripe.Checkout; @@ -10,21 +15,20 @@ namespace SerbleAPI.API.v1.Payments; [Route("api/v1/payments")] [Controller] -public class CreateCheckoutController : ControllerManager { - +public class CreateCheckoutController( + IOptions apiSettings, + IUserRepository userRepo, + ITokenService tokens) : ControllerManager { + [HttpPost("checkout")] - public ActionResult CreateCheckoutSession([FromHeader] SerbleAuthorizationHeader authorization, [FromBody] JsonDocument body, [FromQuery] string mode = "subscription") { - if (!authorization.Check(out string scopes, out SerbleAuthorizationHeaderType? _, out string _, out User? target)) { + [Authorize(Policy = "Scope:PaymentInfo")] + public ActionResult CreateCheckoutSession([FromBody] JsonDocument body, [FromQuery] string mode = "subscription") { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) { return Unauthorized(); } - ScopeHandler.ScopesEnum[] scopeStringToEnums = ScopeHandler.ScopeStringToEnums(scopes).ToArray(); - if (!scopeStringToEnums.Contains(ScopeHandler.ScopesEnum.PaymentInfo) && !scopeStringToEnums.Contains(ScopeHandler.ScopesEnum.FullAccess)) { - return Forbid(); - } - - string domain = Program.Config!["website_url"]; - + string domain = apiSettings.Value.WebsiteUrl; string[] lookupKeys; try { lookupKeys = ProductManager.CheckoutBodyToLookupIds(body, out _); @@ -32,163 +36,79 @@ public ActionResult CreateCheckoutSession([FromHeader] SerbleAuthorizat catch (KeyNotFoundException e) { return NotFound("Invalid product ID: " + e.Message); } - PriceListOptions priceOptions = new() { - LookupKeys = lookupKeys.ToList() - }; - PriceService priceService = new(); - StripeList prices = priceService.List(priceOptions); + StripeList prices = new PriceService().List(new PriceListOptions { + LookupKeys = lookupKeys.ToList() + }); if (!prices.Any()) { return BadRequest("No valid items were provided"); } target.EnsureStripeCustomer(); - SessionCreateOptions options = new() { - LineItems = new List { - new() { - Price = prices.Data[0].Id, - Quantity = 1, - }, - }, + Session session = new SessionService().Create(new SessionCreateOptions { + LineItems = [ + new SessionLineItemOptions { + Price = prices.Data[0].Id, Quantity = 1 + } + ], Mode = mode, SuccessUrl = domain + "/store/success?session_id={CHECKOUT_SESSION_ID}", CancelUrl = domain + "/store/cancel", ClientReferenceId = target.Id, Customer = target.StripeCustomerId - }; - - SessionService service = new(); - Session session = service.Create(options); - - Response.Headers.Add("Location", session.Url); + }); + Response.Headers.Append("Location", session.Url); return new { url = session.Url }; } - + + // Anonymous checkout — no user account needed [HttpPost("checkoutanon")] + [AllowAnonymous] public ActionResult CreateAnonCheckoutSession([FromBody] JsonDocument body, [FromQuery] string mode = "payment") { - string domain = Program.Config!["website_url"]; - + string domain = apiSettings.Value.WebsiteUrl; string[] lookupKeys; List prods; - try { - lookupKeys = ProductManager.CheckoutBodyToLookupIds(body, out prods); - } - catch (KeyNotFoundException e) { - return NotFound("Invalid product ID: " + e.Message); - } - PriceListOptions priceOptions = new() { - LookupKeys = lookupKeys.ToList() - }; - PriceService priceService = new(); - StripeList prices = priceService.List(priceOptions); + try { lookupKeys = ProductManager.CheckoutBodyToLookupIds(body, out prods); } + catch (KeyNotFoundException e) { return NotFound("Invalid product ID: " + e.Message); } + + StripeList prices = new PriceService().List(new PriceListOptions { LookupKeys = lookupKeys.ToList() }); + if (!prices.Any()) return BadRequest("No valid items were provided"); - if (!prices.Any()) { - return BadRequest("No valid items were provided"); - } - string surl = domain + "/store/success?session_id={CHECKOUT_SESSION_ID}"; if (prods.Count == 1 && prods.Single().SuccessRedirect != null) { - // Generate a token for if it succeeds - string tok = - TokenHandler.GenerateCheckoutSuccessToken(prods.Single().Id, prods.Single().SuccessTokenSecret!); + string tok = tokens.GenerateCheckoutSuccessToken(prods.Single().Id, prods.Single().SuccessTokenSecret!); surl = prods.Single().SuccessRedirect!.Replace("{token}", tok); } - - SessionCreateOptions options = new() { - LineItems = new List { - new() { - Price = prices.Data[0].Id, - Quantity = 1, - }, - }, - Mode = mode, - SuccessUrl = surl, - CancelUrl = domain + "/store/cancel" - }; - - SessionService service = new(); - Session session = service.Create(options); - - Response.Headers.Add("Location", session.Url); + Session session = new SessionService().Create(new SessionCreateOptions { + LineItems = [new SessionLineItemOptions { Price = prices.Data[0].Id, Quantity = 1 }], + Mode = mode, SuccessUrl = surl, CancelUrl = domain + "/store/cancel" + }); + Response.Headers.Append("Location", session.Url); return new { url = session.Url }; } - + [HttpPost("portal")] [Obsolete("Customer ID is no longer provided to clients.")] + [AllowAnonymous] public ActionResult CreatePortalSession() { - string customerId = Request.Form["customer_id"]; - - // This is the URL to which your customer will return after - // they are done managing billing in the Customer Portal. - string returnUrl = Program.Config!["website_url"]; - - Stripe.BillingPortal.SessionCreateOptions options = new() { - Customer = customerId, - ReturnUrl = returnUrl, - }; - Stripe.BillingPortal.SessionService service = new(); + string customerId = Request.Form["customer_id"]!; + Stripe.BillingPortal.SessionCreateOptions options = new() { Customer = customerId, ReturnUrl = apiSettings.Value.WebsiteUrl }; Stripe.BillingPortal.Session? session; - try { - session = service.Create(options); - } - catch (StripeException) { - return BadRequest("Invalid Customer"); - } - - Response.Headers.Add("Location", session.Url); + try { session = new Stripe.BillingPortal.SessionService().Create(options); } + catch (StripeException) { return BadRequest("Invalid Customer"); } + Response.Headers.Append("Location", session.Url); return new StatusCodeResult(303); } - /// - /// Get the URL for the customer portal. - /// - /// - /// Requires the payment_info scope. - /// - /// A URL that will send the user to their Stripe portal. [HttpGet("portal")] - public ActionResult SendUserToPortal([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.Check(out string scopes, out SerbleAuthorizationHeaderType? type, out string _, out User target)) { - return Unauthorized(); - } - - ScopeHandler.ScopesEnum[] scopeStringToEnums = ScopeHandler.ScopeStringToEnums(scopes).ToArray(); - if (!scopeStringToEnums.Contains(ScopeHandler.ScopesEnum.PaymentInfo) && !scopeStringToEnums.Contains(ScopeHandler.ScopesEnum.FullAccess)) { - return Forbid("Insufficient scope"); - } - + [Authorize(Policy = "Scope:PaymentInfo")] + public ActionResult SendUserToPortal() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); target.EnsureStripeCustomer(); - string? stripeCustomerId = target.StripeCustomerId; - - string returnUrl = Program.Config!["website_url"]; - - Stripe.BillingPortal.SessionCreateOptions options = new() { - Customer = stripeCustomerId, - ReturnUrl = returnUrl, - }; - Stripe.BillingPortal.SessionService service = new(); - Stripe.BillingPortal.Session? session = service.Create(options); - - Response.Headers.Add("Location", session.Url); + Stripe.BillingPortal.Session session = new Stripe.BillingPortal.SessionService().Create( + new Stripe.BillingPortal.SessionCreateOptions { Customer = target.StripeCustomerId, ReturnUrl = apiSettings.Value.WebsiteUrl }); + Response.Headers.Append("Location", session.Url); return new { url = session.Url }; } - - [HttpOptions("checkout")] - public IActionResult OptionsCheckout() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - - [HttpOptions("checkoutanon")] - public IActionResult OptionsCheckoutAnon() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - - [HttpOptions("portal")] - public IActionResult OptionsPortal() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Payments/OrderSuccessController.cs b/API/v1/Payments/OrderSuccessController.cs index e6be9e1..b27a754 100644 --- a/API/v1/Payments/OrderSuccessController.cs +++ b/API/v1/Payments/OrderSuccessController.cs @@ -1,19 +1,20 @@ -using GeneralPurposeLib; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SerbleAPI.Config; using Stripe.Checkout; namespace SerbleAPI.API.v1.Payments; [Route("api/v1/payments/ordersuccess")] [ApiController] -public class OrderSuccessController : ControllerManager { +public class OrderSuccessController(ILogger logger, IOptions apiSettings) : ControllerManager { [HttpGet("{session_id}")] public IActionResult Get(string session_id) { SessionService sessionService = new(); Session session = sessionService.Get(session_id); - Logger.Debug(!session.Livemode ? "Order Success - Test Session" : "Order Success"); - return Redirect(Program.Config!["website_url"] + $"/store/success?session_id={session_id}"); + logger.LogDebug(!session.Livemode ? "Order Success - Test Session" : "Order Success"); + return Redirect(apiSettings.Value.WebsiteUrl + $"/store/success?session_id={session_id}"); } } \ No newline at end of file diff --git a/API/v1/Payments/ProductsController.cs b/API/v1/Payments/ProductsController.cs index 107e526..a4e0cec 100644 --- a/API/v1/Payments/ProductsController.cs +++ b/API/v1/Payments/ProductsController.cs @@ -1,26 +1,20 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data.ApiDataSchemas; +using SerbleAPI.Authentication; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; -namespace SerbleAPI.API.v1.Payments; +namespace SerbleAPI.API.v1.Payments; [ApiController] [Route("api/v1/products")] -public class ProductsController : ControllerManager { - +[Authorize] +public class ProductsController(IUserRepository userRepo, IProductRepository productRepo) : ControllerManager { + [HttpGet] - public ActionResult Get([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.CheckAndGetInfo(out User target, out Dictionary t, null, true, Request)) { - return Unauthorized(); - } - Program.StorageService!.GetOwnedProducts(target.Id, out string[] prods); - return prods; - } - - [HttpOptions] - public IActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); + public ActionResult Get() { + User? target = HttpContext.User.GetUser(userRepo); + if (target == null) return Unauthorized(); + return productRepo.GetOwnedProducts(target.Id); } - } \ No newline at end of file diff --git a/API/v1/Payments/StripeWebhookController.cs b/API/v1/Payments/StripeWebhookController.cs index 3bb1cc6..6abb9d8 100644 --- a/API/v1/Payments/StripeWebhookController.cs +++ b/API/v1/Payments/StripeWebhookController.cs @@ -1,186 +1,152 @@ -using GeneralPurposeLib; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using SerbleAPI.Config; using SerbleAPI.Data; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; using Stripe; using Stripe.Checkout; -namespace SerbleAPI.API.v1.Payments; +namespace SerbleAPI.API.v1.Payments; [Route("api/v1/payments/webhook")] [ApiController] -public class StripeWebhookController : ControllerManager { - +public class StripeWebhookController( + ILogger logger, + IOptions settings, + IOptions emailSettings, + IUserRepository userRepo, + IProductRepository productRepo) : ControllerManager { + [HttpPost] public async Task StripeWebhookCallback() { string json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); - string endpointSecret = Program.Testing ? Program.Config!["stripe_testing_webhook_secret"] : Program.Config!["stripe_webhook_secret"]; + string endpointSecret = settings.Value.ApiKey; try { Event? stripeEvent = EventUtility.ParseEvent(json); StringValues signatureHeader = Request.Headers["Stripe-Signature"]; - stripeEvent = EventUtility.ConstructEvent(json, - signatureHeader, endpointSecret); + stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, endpointSecret); bool liveMode = stripeEvent.Livemode; - bool fulfillOrderForNonAdmins = Program.Config["give_products_to_non_admins_while_testing"] == "true"; + switch (stripeEvent.Type) { case Events.CustomerSubscriptionDeleted: { if (stripeEvent.Data.Object is not Subscription subscription) break; - Logger.Debug("Subscription canceled: " + subscription.Id); - // Remove the user's subscription - Program.StorageService!.GetUserFromStripeCustomerId(subscription.CustomerId, out User? user); - if (user == null) { - // User probably deleted their account - Logger.Debug("User not found for subscription: " + subscription.Id); - break; - } - + logger.LogDebug("Subscription canceled: " + subscription.Id); + User? user = userRepo.GetUserFromStripeCustomerId(subscription.CustomerId); + if (user == null) { logger.LogDebug("User not found for subscription: " + subscription.Id); break; } + foreach (SubscriptionItem subscriptionItem in subscription.Items) { SerbleProduct? prod = ProductManager.GetProductFromPriceId(subscriptionItem.Price.Id); - if (prod == null) { - continue; - } - Program.StorageService.RemoveOwnedProduct(user.Id, prod.Id); - Logger.Debug("Removed product " + prod.Name + " from user " + user.Username); + if (prod == null) continue; + productRepo.RemoveOwnedProduct(user.Id, prod.Id); + logger.LogDebug("Removed product " + prod.Name + " from user " + user.Username); } - if (liveMode || fulfillOrderForNonAdmins || user.IsAdmin()) { - Program.StorageService.UpdateUser(user); + if (liveMode || user.IsAdmin()) { + userRepo.UpdateUser(user); } else { - Logger.Debug("Not removing subscription for user " + user.Username + - " because they are not an admin and we are not in live mode"); + logger.LogDebug("Not removing subscription for user " + user.Username + + " because they are not an admin and we are not in live mode"); } - // Send email if (user.VerifiedEmail) { string emailBody = EmailSchemasService.GetEmailSchema(EmailSchema.SubscriptionEnded, LocalisationHandler.LanguageOrDefault(user)); emailBody = emailBody.Replace("{name}", user.Username); - Email email = new(user.Email.ToSingleItemEnumerable().ToArray(), FromAddress.System, - "Subscription Cancelled", emailBody); + Email email = new(logger, emailSettings.Value, user.Email.ToSingleItemEnumerable().ToArray(), + FromAddress.System, "Subscription Cancelled", emailBody); email.SendNonBlocking(); } - break; } case Events.CustomerSubscriptionUpdated: { Subscription? subscription = stripeEvent.Data.Object as Subscription; - Logger.Debug("Subscription updated: " + subscription!.Id); + logger.LogDebug("Subscription updated: " + subscription!.Id); break; } case Events.CheckoutSessionCompleted: { - if (stripeEvent.Data.Object is not Session session) { - break; // Error maybe? - } + if (stripeEvent.Data.Object is not Session session) break; - Program.StorageService!.GetUser(session.ClientReferenceId, out User? user); - if (user == null) { - // User probably deleted their account - Logger.Debug("User not found for checkout session: " + session.Id); - break; - } + User? user = userRepo.GetUser(session.ClientReferenceId); + if (user == null) { logger.LogDebug("User not found for checkout session: " + session.Id); break; } - Logger.Debug("Checkout session completed: " + session.Id + " for user " + user.Username); + logger.LogDebug("Checkout session completed: " + session.Id + " for user " + user.Username); - // Get what was purchased - SessionListLineItemsOptions options = new() { - Limit = 5 - }; - SessionService service = new(); - StripeList lineItems = await service.ListLineItemsAsync(session.Id, options); - if (lineItems.Data.Count == 0) { - Logger.Warn("No line items found for session: " + session.Id); - } + StripeList lineItems = await new SessionService() + .ListLineItemsAsync(session.Id, new SessionListLineItemsOptions { Limit = 5 }); + + if (lineItems.Data.Count == 0) + logger.LogWarning("No line items found for session: " + session.Id); - List purchasedItems = new(); - List itemIds = new(); + List purchasedItems = []; + List itemIds = []; lineItems.Data.ForEach(item => { - Logger.Debug("Item Bought: " + item.Description); + logger.LogDebug("Item Bought: " + item.Description); purchasedItems.Add($"
  • {item.Description}
  • "); - SerbleProduct? product = ProductManager.GetProductFromPriceId(item.Price.Id); - if (product == null) { - Logger.Error("Unknown item bought: " + item.Price.Id); - } - - itemIds.Add(product!.Id); + if (product == null) { logger.LogError("Unknown item bought: " + item.Price.Id); } + else { itemIds.Add(product.Id); } }); - if (liveMode || fulfillOrderForNonAdmins || user.IsAdmin()) { - Program.StorageService.AddOwnedProducts(user.Id, itemIds.ToArray()); + + if (liveMode || user.IsAdmin()) { + productRepo.AddOwnedProducts(user.Id, itemIds.ToArray()); } else { - Logger.Debug("Not fulfilling order because we are not in live mode and user is not admin"); + logger.LogDebug("Not fulfilling order because we are not in live mode and user is not admin"); } if (user.VerifiedEmail) { string emailBody = EmailSchemasService.GetEmailSchema(EmailSchema.PurchaseReceipt, LocalisationHandler.LanguageOrDefault(user)); - emailBody = emailBody.Replace("{name}", user.Username); - emailBody = emailBody.Replace("{products}", string.Join(", ", purchasedItems)); - Email email = new(new[] {user.Email}, FromAddress.System, "Purchase Receipt", emailBody); + emailBody = emailBody.Replace("{name}", user.Username) + .Replace("{products}", string.Join(", ", purchasedItems)); + Email email = new(logger, emailSettings.Value, [user.Email], + FromAddress.System, "Purchase Receipt", emailBody); email.SendNonBlocking(); } - break; } - case Events.CustomerSubscriptionCreated: { + case Events.CustomerSubscriptionCreated: break; - } case Events.CustomerSubscriptionTrialWillEnd: { Subscription subscription = (stripeEvent.Data.Object as Subscription).ThrowIfNull(); - Program.StorageService!.GetUserFromStripeCustomerId(subscription.CustomerId, out User? user); - if (user == null) { - // User probably deleted their account - Logger.Debug("User not found for subscription: " + subscription.Id); - break; - } - - if (!user.VerifiedEmail) { - break; - } - + User? user = userRepo.GetUserFromStripeCustomerId(subscription.CustomerId); + if (user == null) { logger.LogDebug("User not found for subscription: " + subscription.Id); break; } + if (!user.VerifiedEmail) break; if (subscription.TrialEnd == null) { - // No trial - Logger.Error( - "No trial end date found for subscription in Trial End webhook: " + subscription.Id); + logger.LogError("No trial end date found for subscription in Trial End webhook: " + subscription.Id); break; } - - string emailBody = EmailSchemasService.GetEmailSchema(EmailSchema.FreeTrialEnding, LocalisationHandler.LanguageOrDefault(user)); - emailBody = emailBody.Replace("{name}", user.Username) + string emailBody2 = EmailSchemasService.GetEmailSchema(EmailSchema.FreeTrialEnding, LocalisationHandler.LanguageOrDefault(user)); + emailBody2 = emailBody2.Replace("{name}", user.Username) .Replace("{trial_end_date}", subscription.TrialEnd.Value.ToString("MMMM dd, yyyy")) .Replace("{trial_end_time}", subscription.TrialEnd.Value.ToString("h:mm tt")); - Email email = new(user.Email.ToSingleItemEnumerable().ToArray(), FromAddress.System, - "Subscription Trial Ending", emailBody); - email.SendNonBlocking(); + Email email2 = new(logger, emailSettings.Value, + user.Email.ToSingleItemEnumerable().ToArray(), + FromAddress.System, "Subscription Trial Ending", emailBody2); + email2.SendNonBlocking(); break; } default: - Logger.Error("Unhandled event type: " + stripeEvent.Type); + logger.LogError("Unhandled event type: " + stripeEvent.Type); break; } return Ok(); } catch (StripeException e) { - Logger.Debug("Stripe exception: " + e.Message); + logger.LogDebug("Stripe exception: " + e.Message); return BadRequest(); } catch (Exception e) { - Logger.Error(e); + logger.LogError(e.ToString()); return BadRequest(); } } - - [HttpOptions] - public IActionResult OptionsWeb() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/People/AdamController.cs b/API/v1/People/AdamController.cs index 2c6f7e4..9004b3e 100644 --- a/API/v1/People/AdamController.cs +++ b/API/v1/People/AdamController.cs @@ -40,15 +40,16 @@ public IActionResult Patch() { return Ok("I have been patched"); } + // OPTIONS are dead code, but these are kept + // because they're funny. + [HttpOptions] public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Thank you"); } [HttpOptions("{cat:int}")] public ActionResult OptionsArg() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Thank you"); } diff --git a/API/v1/People/DanController.cs b/API/v1/People/DanController.cs index 54f3ed8..ed91c76 100644 --- a/API/v1/People/DanController.cs +++ b/API/v1/People/DanController.cs @@ -36,15 +36,16 @@ public IActionResult Patch() { return Ok("Yay I've been patched"); } + // OPTIONS are dead code, but these are kept + // because they're funny. + [HttpOptions] public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Stop"); } [HttpOptions("{arg}")] public ActionResult OptionsArg() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Stop"); } diff --git a/API/v1/People/README.md b/API/v1/People/README.md index 0511dc5..3dd8662 100644 --- a/API/v1/People/README.md +++ b/API/v1/People/README.md @@ -1,2 +1,2 @@ # People -I was bored okay. I wanted to make a people API. I'm not sure why. I just did. So here it is. \ No newline at end of file +I was bored okay. I wanted to make a people API. I'm not sure why. I just did. So here it is. diff --git a/API/v1/People/TristanController.cs b/API/v1/People/TristanController.cs index 6ca5d28..c43e5d5 100644 --- a/API/v1/People/TristanController.cs +++ b/API/v1/People/TristanController.cs @@ -36,15 +36,16 @@ public IActionResult Patch() { return Ok("Why?"); } + // OPTIONS are dead code, but these are kept + // because they're funny. + [HttpOptions] public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Can I pick a different one?"); } [HttpOptions("{cat:int}")] public ActionResult OptionsArg() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); return Ok("Can I pick a different one?"); } diff --git a/API/v1/RootController.cs b/API/v1/RootController.cs index 997d9ea..34309f0 100644 --- a/API/v1/RootController.cs +++ b/API/v1/RootController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; namespace SerbleAPI.API.v1; @@ -11,10 +10,4 @@ public class RootController : ControllerManager { public IActionResult Get() { return Ok("Serble API. View the project on GitHub (https://github.com/Serble/SerbleAPI)."); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } -} +} \ No newline at end of file diff --git a/API/v1/Services/CheckUser.cs b/API/v1/Services/CheckUser.cs index dd0c1bb..e647b04 100644 --- a/API/v1/Services/CheckUser.cs +++ b/API/v1/Services/CheckUser.cs @@ -22,11 +22,4 @@ public IActionResult Get([FromQuery] string redirect, [FromQuery] bool redirectO return Redirect(QueryHelpers.AddQueryString(redirect, "success", "true")); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - -} +} \ No newline at end of file diff --git a/API/v1/Services/RawDataController.cs b/API/v1/Services/RawDataController.cs index a794838..92c5a96 100644 --- a/API/v1/Services/RawDataController.cs +++ b/API/v1/Services/RawDataController.cs @@ -11,11 +11,4 @@ public class RawDataController : ControllerManager { public ActionResult Get() { return Ok(RawDataManager.EnglishWords); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - } \ No newline at end of file diff --git a/API/v1/Services/ReCaptchaController.cs b/API/v1/Services/ReCaptchaController.cs index d9cf39f..a32cd0f 100644 --- a/API/v1/Services/ReCaptchaController.cs +++ b/API/v1/Services/ReCaptchaController.cs @@ -1,18 +1,18 @@ using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; using SerbleAPI.Data.Schemas; +using SerbleAPI.Services; namespace SerbleAPI.API.v1.Services; [ApiController] [Route("api/v1/recaptcha")] -public class ReCaptchaController : ControllerManager { +public class ReCaptchaController(IGoogleReCaptchaService reCaptchaService) : ControllerManager { [HttpPost] public async Task> Post([FromQuery] string token) { // Verify - GoogleReCaptchaResponse response = await GoogleReCaptchaHandler.VerifyReCaptcha(token); + GoogleReCaptchaResponse response = await reCaptchaService.VerifyReCaptcha(token); if (!response.Success) { return BadRequest("Error validating recaptcha"); @@ -20,11 +20,4 @@ public async Task> Post([FromQuery] string token) { return Ok(response.Score); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "POST, OPTIONS"); - return Ok("Thank you"); - } - } \ No newline at end of file diff --git a/API/v1/Services/Redirect.cs b/API/v1/Services/Redirect.cs index 17f82f7..cc99d04 100644 --- a/API/v1/Services/Redirect.cs +++ b/API/v1/Services/Redirect.cs @@ -15,11 +15,4 @@ public IActionResult Get([FromQuery] string to) { public IActionResult Post([FromBody] string to) { return Redirect(to); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, OPTIONS"); - return Ok(); - } - -} +} \ No newline at end of file diff --git a/API/v1/ServicesController.cs b/API/v1/ServicesController.cs index 0bb9403..20be501 100644 --- a/API/v1/ServicesController.cs +++ b/API/v1/ServicesController.cs @@ -22,17 +22,4 @@ public async Task> GetService(string id) { } return Ok(status.First()); } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - - [HttpOptions("{id}")] - public ActionResult OptionsArg() { - HttpContext.Response.Headers.Add("Allow", "GET, OPTIONS"); - return Ok(); - } - -} +} \ No newline at end of file diff --git a/API/v1/Vault/NotesController.cs b/API/v1/Vault/NotesController.cs index 7f9ad41..e455799 100644 --- a/API/v1/Vault/NotesController.cs +++ b/API/v1/Vault/NotesController.cs @@ -1,79 +1,53 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data; -using SerbleAPI.Data.ApiDataSchemas; +using SerbleAPI.Authentication; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; -namespace SerbleAPI.API.v1.Vault; +namespace SerbleAPI.API.v1.Vault; [ApiController] [Route("api/v1/vault/notes")] -public class NotesController : ControllerManager { - +[Authorize(Policy = "Scope:Vault")] +public class NotesController(IUserRepository userRepo, INoteRepository noteRepo) : ControllerManager { + [HttpGet] - public ActionResult GetNotes([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.Vault)) { - return Unauthorized(); - } - - Program.StorageService!.GetUserNotes(user.Id, out string[] noteIds); - return Ok(noteIds); + public ActionResult GetNotes() { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + return Ok(noteRepo.GetUserNotes(user.Id)); } - + [HttpGet("{noteId}")] - public ActionResult GetNoteContent([FromHeader] SerbleAuthorizationHeader authorizationHeader, string noteId) { - if (!authorizationHeader.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.Vault)) { - return Unauthorized(); - } - - Program.StorageService!.GetUserNoteContent(user.Id, noteId, out string? content); - return Ok(content); + public ActionResult GetNoteContent(string noteId) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + return Ok(noteRepo.GetUserNoteContent(user.Id, noteId)); } - - [HttpPost] - public ActionResult CreateNote([FromHeader] SerbleAuthorizationHeader authorizationHeader) { - if (!authorizationHeader.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.Vault)) { - return Unauthorized(); - } + [HttpPost] + public ActionResult CreateNote() { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); string id = Guid.NewGuid().ToString(); - Program.StorageService!.CreateUserNote(user.Id, id, ""); - return Ok(new { - success = true, - note_id = id - }); + noteRepo.CreateUserNote(user.Id, id, ""); + return Ok(new { success = true, note_id = id }); } - + [HttpPut("{noteId}")] [RequestSizeLimit(16_000_000)] - public ActionResult UpdateNoteContent([FromHeader] SerbleAuthorizationHeader authorizationHeader, string noteId, [FromBody] string body) { - if (!authorizationHeader.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.Vault)) { - return Unauthorized(); - } - - Program.StorageService!.UpdateUserNoteContent(user.Id, noteId, body); + public ActionResult UpdateNoteContent(string noteId, [FromBody] string body) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + noteRepo.UpdateUserNoteContent(user.Id, noteId, body); return Ok(); } - + [HttpDelete("{noteId}")] - public ActionResult DeleteNoteContent([FromHeader] SerbleAuthorizationHeader authorizationHeader, string noteId) { - if (!authorizationHeader.CheckAndGetInfo(out User user, out _, ScopeHandler.ScopesEnum.Vault)) { - return Unauthorized(); - } - - Program.StorageService!.DeleteUserNote(user.Id, noteId); - return Ok(); - } - - [HttpOptions] - public ActionResult Options() { - HttpContext.Response.Headers.Add("Allow", "GET, POST, OPTIONS"); - return Ok(); - } - - [HttpOptions("{noteId}")] - public ActionResult OptionsNoteId() { - HttpContext.Response.Headers.Add("Allow", "GET, PUT, DELETE, OPTIONS"); + public ActionResult DeleteNoteContent(string noteId) { + User? user = HttpContext.User.GetUser(userRepo); + if (user == null) return Unauthorized(); + noteRepo.DeleteUserNote(user.Id, noteId); return Ok(); } - } \ No newline at end of file diff --git a/Authentication/SerbleAuthenticationHandler.cs b/Authentication/SerbleAuthenticationHandler.cs new file mode 100644 index 0000000..9ed146c --- /dev/null +++ b/Authentication/SerbleAuthenticationHandler.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using SerbleAPI.Services; + +namespace SerbleAPI.Authentication; + +/// +/// Options bag for the Serble authentication scheme (no configuration needed). +/// +public class SerbleAuthenticationOptions : AuthenticationSchemeOptions { } + +/// +/// Custom ASP.NET authentication handler that supports two header formats: +/// +/// SerbleAuth: User <token> — direct user login JWT (full_access) +/// SerbleAuth: App <token> — OAuth access JWT (scoped) +/// +/// Authorization: Bearer <token> — tries user token first, then app token, +/// so both token types work with the standard +/// Authorization header (full backwards compat). +/// +/// On success, HttpContext.User is populated with the following claims: +/// userid — the authenticated user's ID +/// auth_type — "User" or "App" +/// scope — the raw scope bitmask string (e.g. "10000000") +/// +public class SerbleAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ITokenService tokens) + : AuthenticationHandler(options, logger, encoder) { + + public const string SchemeName = "Serble"; + + protected override Task HandleAuthenticateAsync() { + // Primary: custom SerbleAuth header + if (Request.Headers.TryGetValue("SerbleAuth", out var serbleAuthValues)) + return Task.FromResult(HandleSerbleAuthHeader(serbleAuthValues.ToString())); + + // Secondary: standard Authorization header + if (Request.Headers.TryGetValue("Authorization", out var authValues)) + return Task.FromResult(HandleAuthorizationHeader(authValues.ToString())); + + return Task.FromResult(AuthenticateResult.NoResult()); + } + + private AuthenticateResult HandleSerbleAuthHeader(string header) { + string[] parts = header.Split(' ', 2); + if (parts.Length != 2) + return AuthenticateResult.Fail("SerbleAuth header must be in format 'TYPE TOKEN'"); + + return parts[0] switch { + "User" => AuthenticateAsUser(parts[1]), + "App" => AuthenticateAsApp(parts[1]), + _ => AuthenticateResult.Fail($"Unknown SerbleAuth type '{parts[0]}'") + }; + } + + private AuthenticateResult HandleAuthorizationHeader(string header) { + string[] parts = header.Split(' ', 2); + if (parts.Length != 2) + return AuthenticateResult.NoResult(); + + // Only intercept Bearer tokens; Basic auth is handled by the password endpoint + if (!parts[0].Equals("Bearer", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + string token = parts[1]; + + // Try user token first, fall back to app token + AuthenticateResult userResult = AuthenticateAsUser(token); + return userResult.Succeeded ? userResult : AuthenticateAsApp(token); + } + + private AuthenticateResult AuthenticateAsUser(string token) { + if (!tokens.ValidateLoginToken(token, out var user) || user == null) + return AuthenticateResult.Fail("Invalid user token"); + + return BuildTicket([ + new Claim("userid", user.Id), + new Claim("auth_type", "User"), + new Claim("scope", "1") // User tokens always carry full_access + ]); + } + + private AuthenticateResult AuthenticateAsApp(string token) { + if (!tokens.ValidateAccessToken(token, out var appUser, out string scope) || appUser == null) + return AuthenticateResult.Fail("Invalid app access token"); + + return BuildTicket([ + new Claim("userid", appUser.Id), + new Claim("auth_type", "App"), + new Claim("scope", scope) + ]); + } + + private AuthenticateResult BuildTicket(List claims) { + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return AuthenticateResult.Success(ticket); + } +} diff --git a/Authentication/SerbleClaimsPrincipalExtensions.cs b/Authentication/SerbleClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..d2c00cb --- /dev/null +++ b/Authentication/SerbleClaimsPrincipalExtensions.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using SerbleAPI.Data; +using SerbleAPI.Data.Schemas; +using SerbleAPI.Data.ApiDataSchemas; +using SerbleAPI.Repositories; + +namespace SerbleAPI.Authentication; + +/// +/// Convenience extensions for reading Serble-specific claims from a +/// populated by . +/// +public static class SerbleClaimsPrincipalExtensions { + + /// The authenticated user's storage ID, or null if not authenticated. + public static string? GetUserId(this ClaimsPrincipal p) + => p.FindFirstValue("userid"); + + /// The raw scope bitmask string (e.g. "10000001"). + public static string GetScopeString(this ClaimsPrincipal p) + => p.FindFirstValue("scope") ?? "0"; + + /// Whether the token grants a particular scope (or full_access). + public static bool HasScope(this ClaimsPrincipal p, ScopeHandler.ScopesEnum scope) + => p.GetScopeString().SerbleHasScope(scope); + + /// Whether the token was issued directly to a user (not an OAuth app). + public static bool IsUser(this ClaimsPrincipal p) + => p.FindFirstValue("auth_type") == "User"; + + /// Whether the token is an OAuth app access token. + public static bool IsApp(this ClaimsPrincipal p) + => p.FindFirstValue("auth_type") == "App"; + + /// The auth type as the legacy enum value. + public static SerbleAuthorizationHeaderType GetAuthType(this ClaimsPrincipal p) + => p.FindFirstValue("auth_type") switch { + "User" => SerbleAuthorizationHeaderType.User, + "App" => SerbleAuthorizationHeaderType.App, + _ => SerbleAuthorizationHeaderType.Null + }; + + /// + /// Loads and returns the full object for the authenticated + /// principal, with IUserRepository injected so User instance methods work. + /// Requires the to resolve the scoped repo. + /// + public static User? GetUser(this ClaimsPrincipal p, IUserRepository userRepo) { + string? userId = p.GetUserId(); + if (userId == null) return null; + User? user = userRepo.GetUser(userId); + return user?.WithRepos(userRepo); + } +} diff --git a/Config/ApiEmailAddresses.cs b/Config/ApiEmailAddresses.cs new file mode 100644 index 0000000..10a77a7 --- /dev/null +++ b/Config/ApiEmailAddresses.cs @@ -0,0 +1,7 @@ +namespace SerbleAPI.Config; + +public class ApiEmailAddresses { + public string System { get; set; } = null!; + public string Newsletter { get; set; } = null!; + public string Contact { get; set; } = null!; +} diff --git a/Config/ApiSettings.cs b/Config/ApiSettings.cs new file mode 100644 index 0000000..0d73890 --- /dev/null +++ b/Config/ApiSettings.cs @@ -0,0 +1,9 @@ +namespace SerbleAPI.Config; + +public class ApiSettings { + public string BindUrl { get; set; } = null!; + public string WebsiteUrl { get; set; } = null!; + public bool AllowAntiSpamBypass { get; set; } + public string LiveUrl { get; set; } = null!; // where the API is publicly accessible, used for links in emails and such + public Dictionary Redirects { get; set; } = new(); +} diff --git a/Config/EmailSettings.cs b/Config/EmailSettings.cs new file mode 100644 index 0000000..904f4fc --- /dev/null +++ b/Config/EmailSettings.cs @@ -0,0 +1,10 @@ +namespace SerbleAPI.Config; + +public class EmailSettings { + public string SmtpHost { get; set; } = null!; + public int SmtpPort { get; set; } + public string SmtpUsername { get; set; } = null!; + public string SmtpPassword { get; set; } = null!; + + public ApiEmailAddresses Addresses { get; set; } = null!; +} diff --git a/Config/JwtSettings.cs b/Config/JwtSettings.cs new file mode 100644 index 0000000..cdf0efa --- /dev/null +++ b/Config/JwtSettings.cs @@ -0,0 +1,7 @@ +namespace SerbleAPI.Config; + +public class JwtSettings { + public string Audience { get; set; } = null!; + public string Issuer { get; set; } = null!; + public string Secret { get; set; } = null!; +} diff --git a/Config/PasskeySettings.cs b/Config/PasskeySettings.cs new file mode 100644 index 0000000..3c93d9c --- /dev/null +++ b/Config/PasskeySettings.cs @@ -0,0 +1,12 @@ +namespace SerbleAPI.Config; + +public class PasskeySettings { + /// + /// The ID of the relying party. + /// This should be the clean domain name of the website, without any protocol or path. For example, "serble.net". + /// + public string RelyingPartyId { get; set; } = null!; + public string RelyingPartyName { get; set; } = null!; + public string[] AllowedOrigins { get; set; } = null!; + public string ServerIconUrl { get; set; } = null!; +} diff --git a/Config/ReCaptchaSettings.cs b/Config/ReCaptchaSettings.cs new file mode 100644 index 0000000..779c77e --- /dev/null +++ b/Config/ReCaptchaSettings.cs @@ -0,0 +1,5 @@ +namespace SerbleAPI.Config; + +public class ReCaptchaSettings { + public string SecretKey { get; set; } = null!; +} diff --git a/Config/StripeSettings.cs b/Config/StripeSettings.cs new file mode 100644 index 0000000..7c501be --- /dev/null +++ b/Config/StripeSettings.cs @@ -0,0 +1,7 @@ +namespace SerbleAPI.Config; + +public class StripeSettings { + public string ApiKey { get; set; } = null!; + public string WebhookSecret { get; set; } = null!; + public string PremiumSubscriptionId { get; set; } = null!; +} diff --git a/Config/TurnstileSettings.cs b/Config/TurnstileSettings.cs new file mode 100644 index 0000000..5d45b6d --- /dev/null +++ b/Config/TurnstileSettings.cs @@ -0,0 +1,5 @@ +namespace SerbleAPI.Config; + +public class TurnstileSettings { + public string SecretKey { get; set; } = null!; +} diff --git a/Data/ApiDataSchemas/AccountEditRequest.cs b/Data/ApiDataSchemas/AccountEditRequest.cs index 886db4f..fd21349 100644 --- a/Data/ApiDataSchemas/AccountEditRequest.cs +++ b/Data/ApiDataSchemas/AccountEditRequest.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -using GeneralPurposeLib; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; namespace SerbleAPI.Data.ApiDataSchemas; @@ -13,14 +13,14 @@ public AccountEditRequest(string field, string newValue) { NewValue = newValue; } - private User ApplyChanges(User target) { + private User ApplyChanges(User target, IUserRepository userRepo) { switch (Field.ToLower()) { case "username": // Check if username is taken if (NewValue == "") { throw new ArgumentException("Username cannot be empty"); } - Program.StorageService!.GetUserFromName(NewValue, out User? existingUser); + User? existingUser = userRepo.GetUserFromName(NewValue); if (existingUser != null) { throw new ArgumentException("Username is already taken"); } @@ -37,8 +37,7 @@ private User ApplyChanges(User target) { break; case "email": - if (!Regex.IsMatch(NewValue, @"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])")) { - Logger.Debug("Email is not valid: " + NewValue); + if (!Regex.IsMatch(NewValue, """(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""")) { throw new ArgumentException("Invalid email"); } target.Email = NewValue; @@ -73,9 +72,9 @@ private User ApplyChanges(User target) { return target; } - public bool TryApplyChanges(User target, out User newUser, out string msg) { + public bool TryApplyChanges(User target, out User newUser, out string msg, IUserRepository userRepo) { try { - newUser = ApplyChanges(target); + newUser = ApplyChanges(target, userRepo); msg = "Success"; return true; } catch (ArgumentException e) { @@ -85,4 +84,4 @@ public bool TryApplyChanges(User target, out User newUser, out string msg) { } } -} +} \ No newline at end of file diff --git a/Data/ApiDataSchemas/AntiSpamHeader.cs b/Data/ApiDataSchemas/AntiSpamHeader.cs new file mode 100644 index 0000000..0e89516 --- /dev/null +++ b/Data/ApiDataSchemas/AntiSpamHeader.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SerbleAPI.Data.ApiDataSchemas; + +public class AntiSpamHeader { + + [FromHeader] + // Either: + // ReCaptcha | recaptcha TOKEN + // Turnstile | turnstile TOKEN + // Testing Bypass | bypass testing + // Or be logged in with a verified email + public string SerbleAntiSpam { get; set; } = null!; +} \ No newline at end of file diff --git a/Data/ApiDataSchemas/AuthorizationHeaderApp.cs b/Data/ApiDataSchemas/AuthorizationHeaderApp.cs deleted file mode 100644 index 322f6aa..0000000 --- a/Data/ApiDataSchemas/AuthorizationHeaderApp.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.ApiDataSchemas; - -public class AuthorizationHeaderApp { - - [FromHeader] - // Format: "SECRET USERID" - public string SerbleAuth { get; set; } = null!; - - public bool Check(string appId, out string[]? scopes, out User? user, out string? msg) { - scopes = null; - user = null; - msg = null; - - if (string.IsNullOrEmpty(SerbleAuth)) { - msg = "Authorization header is missing"; - return false; - } - - string[] parts = SerbleAuth.Split(' '); - if (parts.Length != 2) { - msg = "Header is not in the correct format"; - return false; - } - string secret = parts[0]; - string userId = parts[1]; - - // Find app - Program.StorageService!.GetOAuthApp(appId, out OAuthApp? app); - if (app == null) { - msg = "App null"; - return false; - } - - if (app.ClientSecret != secret) { - msg = "Client secret is not correct"; - return false; - } - - // Check if app is authorized for user - Program.StorageService.GetUser(userId, out user); - if (user == null) { - msg = "User not found"; - return false; - } - - if (user.PermLevel < (int) AccountAccessLevel.Normal) { - msg = "User account is disabled"; - return false; - } - - if (!user.AuthorizedAppIds.Contains(appId)) { - msg = "App unauthorized for user"; - return false; - } - - scopes = ScopeHandler.StringToListOfScopeIds( - user.AuthorizedApps - .Where(appObj => appObj.AppId == appId) - .Select(appObj2 => appObj2.Scopes) - .First()); - - msg = "Check success"; - return true; - } - -} \ No newline at end of file diff --git a/Data/ApiDataSchemas/AuthorizationHeaderUser.cs b/Data/ApiDataSchemas/AuthorizationHeaderUser.cs deleted file mode 100644 index e472cc7..0000000 --- a/Data/ApiDataSchemas/AuthorizationHeaderUser.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.ApiDataSchemas; - -public class AuthorizationHeaderUser { - - [FromHeader] - // Format: "APPID SECRET" - public string SerbleAuth { get; set; } = null!; - - public bool Check(out string? msg) { - msg = null; - - if (string.IsNullOrEmpty(SerbleAuth)) { - msg = "Authorization header is missing"; - return false; - } - - string[] parts = SerbleAuth.Split(' '); - if (parts.Length != 3) { - msg = "Header is not in the correct format"; - return false; - } - - string appId = parts[0]; - string secret = parts[1]; - - // Find app - Program.StorageService!.GetOAuthApp(appId, out OAuthApp? app); - if (app == null) { - msg = "App null"; - return false; - } - - if (app.ClientSecret != secret) { - msg = "Client secret is not correct"; - return false; - } - - msg = "Check success"; - return true; - } - -} \ No newline at end of file diff --git a/Data/ApiDataSchemas/SerbleAuthorizationHeader.cs b/Data/ApiDataSchemas/SerbleAuthorizationHeader.cs index 4876c5b..f2fe89a 100644 --- a/Data/ApiDataSchemas/SerbleAuthorizationHeader.cs +++ b/Data/ApiDataSchemas/SerbleAuthorizationHeader.cs @@ -1,117 +1,7 @@ -using Microsoft.AspNetCore.Mvc; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.ApiDataSchemas; - -public class SerbleAuthorizationHeader { - - [FromHeader] - // Format: "TYPE DATA" - // App: "App ACCESS_TOKEN" - // User: "User TOKEN" - public string SerbleAuth { get; set; } = null!; - - /// - /// Checks the header and authenticates the user. - /// Out objects should only be accessed if the function returns true. - /// - /// The authorized scopes - /// The type of header that was provided - /// The reason why a return was made - /// The user whose account the current app has access to - /// Whether authentication was successful - /// OAuth is not implemented yet - public bool Check(out string scopes, out SerbleAuthorizationHeaderType? headerType, out string msg, out User target) { - msg = null!; - target = null!; - scopes = "0"; - headerType = null; - - if (string.IsNullOrEmpty(SerbleAuth)) { - msg = "Authorization header is missing"; - return false; - } - - string[] parts = SerbleAuth.Split(' '); - if (parts.Length != 2) { - msg = "Header is not in the correct format (TYPE DATA)"; - return false; - } - - string type = parts[0]; - string data = parts[1]; - - switch (type) { - default: - msg = "Header type is not supported"; - return false; - - // App auth - case "App": - headerType = SerbleAuthorizationHeaderType.App; - if (!TokenHandler.ValidateAccessToken(data, out User? appUser, out string scope)) { - msg = "Access token is invalid"; - return false; - } - target = appUser!; - scopes = scope; - return true; - - // User auth - case "User": - // Data is the token - headerType = SerbleAuthorizationHeaderType.User; - if (!TokenHandler.ValidateLoginToken(data, out User? user) || user == null) { - msg = "Invalid token"; - return false; - } - target = user; - scopes = "1"; // 1 is full_access - return true; - } - - } - - public bool CheckAndGetInfo(out User user, - out Dictionary userTranslations, - ScopeHandler.ScopesEnum? requiredScope = null, - bool allowApps = true, - HttpRequest? request = null) { - return CheckAndGetInfo(out user, out userTranslations, out SerbleAuthorizationHeaderType _, out string _, requiredScope, allowApps, request); - } - - public bool CheckAndGetInfo(out User user, - out Dictionary userTranslations, - out SerbleAuthorizationHeaderType type, - out string scopes, - ScopeHandler.ScopesEnum? requiredScope = null, - bool allowApps = true, - HttpRequest? request = null) { - - // Init defaults - user = null!; - userTranslations = null!; - type = SerbleAuthorizationHeaderType.Null; - - if (!Check(out scopes, out SerbleAuthorizationHeaderType? gottenType, out string _, out User target)) { - return false; - } - type = gottenType!.Value; - if (type == SerbleAuthorizationHeaderType.App && !allowApps) { - return false; - } - if (requiredScope != null && !scopes.SerbleHasScope(requiredScope.Value)) { - return false; - } - user = target; - userTranslations = LocalisationHandler.GetTranslations(LocalisationHandler.GetPreferredLanguageOrDefault(request, target)); - return true; - } - -} +namespace SerbleAPI.Data.ApiDataSchemas; public enum SerbleAuthorizationHeaderType { App, User, Null -} \ No newline at end of file +} diff --git a/Data/Email.cs b/Data/Email.cs index c9ba517..f14d0f4 100644 --- a/Data/Email.cs +++ b/Data/Email.cs @@ -1,6 +1,6 @@ using System.Net; using System.Net.Mail; -using GeneralPurposeLib; +using SerbleAPI.Config; using SerbleAPI.Data.Schemas; namespace SerbleAPI.Data; @@ -11,25 +11,34 @@ public class Email { public string From { get; } public string Subject { get; set; } public string Body { get; set; } + + public EmailSettings Settings { get; } + public ILogger Logger { get; } - public Email(IEnumerable to, FromAddress from = FromAddress.System, string subject = "", string body = "") { + public Email(ILogger logger, EmailSettings settings, IEnumerable to, FromAddress from = FromAddress.System, string subject = "", string body = "") { + Settings = settings; + Logger = logger; + To = to.Select(usr => usr.Email).ToArray(); From = FromAddressEnumToString(from); Subject = subject; Body = body; } - public Email(string[] to, FromAddress from = FromAddress.System, string subject = "", string body = "") { + public Email(ILogger logger, EmailSettings settings, string[] to, FromAddress from = FromAddress.System, string subject = "", string body = "") { + Settings = settings; + Logger = logger; + To = to; From = FromAddressEnumToString(from); Subject = subject; Body = body; } - private static string FromAddressEnumToString(FromAddress address) { + private string FromAddressEnumToString(FromAddress address) { return address switch { - FromAddress.System => Program.Config!["EmailAddress_System"], - FromAddress.Newsletter => Program.Config!["EmailAddress_Newsletter"], + FromAddress.System => Settings.Addresses.System, + FromAddress.Newsletter => Settings.Addresses.Newsletter, _ => throw new InvalidEmailException("Invalid FromAddress") }; } @@ -43,7 +52,7 @@ public async Task SendAsync() { await GenerateClient().SendMailAsync(CollateMessage()); } catch (Exception e) { - Logger.Error("Email failed to send: " + e); + Logger.LogError("Email failed to send: " + e); } } @@ -52,14 +61,14 @@ public void Send() { GenerateClient().Send(CollateMessage()); } catch (Exception e) { - Logger.Error("Email failed to send: " + e); + Logger.LogError("Email failed to send: " + e); } } - private static SmtpClient GenerateClient() { - SmtpClient client = new (Program.Config!["smtp_host"]) { - Port = int.Parse(Program.Config!["smtp_port"]), - Credentials = new NetworkCredential(Program.Config["smtp_username"], Program.Config["smtp_password"]), + private SmtpClient GenerateClient() { + SmtpClient client = new (Settings.SmtpHost) { + Port = Settings.SmtpPort, + Credentials = new NetworkCredential(Settings.SmtpUsername, Settings.SmtpPassword), EnableSsl = true }; return client; @@ -88,6 +97,4 @@ public enum FromAddress { Newsletter } -public class InvalidEmailException : Exception { - public InvalidEmailException(string message) : base(message) { } -} \ No newline at end of file +public class InvalidEmailException(string message) : Exception(message); diff --git a/Data/EmailConfirmationService.cs b/Data/EmailConfirmationService.cs deleted file mode 100644 index aa92158..0000000 --- a/Data/EmailConfirmationService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using GeneralPurposeLib; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data; - -public static class EmailConfirmationService { - - public static void SendConfirmationEmail(User user) { - if (user.VerifiedEmail) { - throw new Exception("User has already verified their email"); - } - - string body = EmailSchemasService.GetEmailSchema(EmailSchema.ConfirmationEmail, LocalisationHandler.LanguageOrDefault(user)); - body = body.Replace("{name}", user.Username); - body = body.Replace( - "{confirmation_link}", - Program.Config!["my_host"] + "api/v1/emailconfirm?token=" + TokenHandler.GenerateEmailConfirmationToken(user.Id, user.Email)); - - Email confirmationEmail = new (user.Email.ToSingleItemEnumerable().ToArray()) { - Subject = "Serble Email Confirmation", - Body = body - }; - - confirmationEmail.SendAsync().ContinueWith(_ => Logger.Debug("Sent confirmation email to " + user.Email)); - Logger.Debug("Sending confirmation email to " + user.Email); - } - -} \ No newline at end of file diff --git a/Data/LocalisationHandler.cs b/Data/LocalisationHandler.cs index d411a96..7ef7bc6 100644 --- a/Data/LocalisationHandler.cs +++ b/Data/LocalisationHandler.cs @@ -1,11 +1,9 @@ -using GeneralPurposeLib; using SerbleAPI.Data.Schemas; using YamlDotNet.RepresentationModel; namespace SerbleAPI.Data; public static class LocalisationHandler { - private static string[]? _supportLanguages; private const string DefaultLanguage = "eng"; private static Dictionary>? _translations; @@ -92,7 +90,6 @@ private static void LoadSupportedLanguages() { foreach (string lang in _supportLanguages) { string translationPath = Path.Combine("Translations", lang, "translations.yaml"); if (!File.Exists(translationPath)) { - Logger.Error($"Translation file for language '{lang}' does not exist"); continue; } string translationYaml = File.ReadAllText(translationPath); @@ -101,7 +98,6 @@ private static void LoadSupportedLanguages() { yaml.Load(reader); YamlMappingNode? root = (YamlMappingNode) yaml.Documents[0].RootNode; if (root == null!) { - Logger.Error($"Translation file for language '{lang}' is empty"); continue; } Dictionary translations = new(); @@ -115,14 +111,12 @@ private static void LoadSupportedLanguages() { // Add any keys in the default language but not in other languages to all languages if (!_translations.ContainsKey(DefaultLanguage)) { - Logger.Error($"Default language '{DefaultLanguage}' does not exist"); return; } Dictionary defaultTranslations = _translations[DefaultLanguage]; foreach (string lang in _supportLanguages) { if (lang == DefaultLanguage) continue; if (!_translations.ContainsKey(lang)) { - Logger.Error($"Language '{lang}' does not exist"); continue; } Dictionary translations = _translations[lang]; diff --git a/Data/ProductManager.cs b/Data/ProductManager.cs index c422188..dacb2cc 100644 --- a/Data/ProductManager.cs +++ b/Data/ProductManager.cs @@ -1,18 +1,18 @@ using System.Text.Json; -using GeneralPurposeLib; using Newtonsoft.Json; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; using File = System.IO.File; namespace SerbleAPI.Data; +// TODO: Have this be a service and DI +// TODO: Load products from regular config not custom one public static class ProductManager { - private static SerbleProduct[] _products = null!; public static void Load() { if (!File.Exists("products.json")) { - Logger.Info("products.json does not exist, creating it"); SerbleProduct[] examples = { new() { Name = "Premium", @@ -32,7 +32,6 @@ public static void Load() { } string json = File.ReadAllText("products.json"); _products = JsonConvert.DeserializeObject(json)!; - Logger.Info($"Loaded {_products.Length} products"); } public static SerbleProduct? GetProductFromPriceId(string priceId) { @@ -47,8 +46,8 @@ public static void Load() { return ids.Select(GetProductFromId).ToArray(); } - public static SerbleProduct[] ListOfProductsFromUser(User target) { - Program.StorageService!.GetOwnedProducts(target.Id, out string[] products); + public static SerbleProduct[] ListOfProductsFromUser(User target, IProductRepository productRepo) { + string[] products = productRepo.GetOwnedProducts(target.Id); return GetProductsFromIds(products).Where(product => product != null).Select(product => product!).ToArray(); } diff --git a/Data/Raw/RawDataManager.cs b/Data/Raw/RawDataManager.cs index 9ced40e..1b688c6 100644 --- a/Data/Raw/RawDataManager.cs +++ b/Data/Raw/RawDataManager.cs @@ -1,14 +1,11 @@ -using GeneralPurposeLib; using Newtonsoft.Json; namespace SerbleAPI.Data.Raw; public static class RawDataManager { - public static string[] EnglishWords = null!; public static void LoadRawData() { - Logger.Info("Loading words.txt"); string jsonArray = File.ReadAllText("Data/Raw/words.txt"); EnglishWords = JsonConvert.DeserializeObject(jsonArray)!; } diff --git a/Data/Schemas/SavedPasskey.cs b/Data/Schemas/SavedPasskey.cs index f82f189..4fbab8f 100644 --- a/Data/Schemas/SavedPasskey.cs +++ b/Data/Schemas/SavedPasskey.cs @@ -3,7 +3,7 @@ namespace SerbleAPI.Data.Schemas; public class SavedPasskey { - public string? OwnerId; + public string OwnerId = null!; public string? Name; public byte[]? CredentialId; public byte[]? PublicKey; diff --git a/Data/Schemas/User.cs b/Data/Schemas/User.cs index aa3d5ba..94ff069 100644 --- a/Data/Schemas/User.cs +++ b/Data/Schemas/User.cs @@ -1,7 +1,7 @@ using System.Text; -using GeneralPurposeLib; using OtpNet; using QRCoder; +using SerbleAPI.Repositories; using Stripe; namespace SerbleAPI.Data.Schemas; @@ -20,7 +20,6 @@ public class User { /// 0=Disabled Account 1=Normal, 2=Admin /// public int PermLevel { get; set; } - public string PermString { get; set; } public string? StripeCustomerId { get; set; } public string? Language { get; set; } public bool TotpEnabled { get; set; } @@ -29,9 +28,7 @@ public class User { public AuthorizedApp[] AuthorizedApps { get { - Logger.Debug("Get was made on AuthorizedApps"); if (_obtainedAuthedApps != null) return _obtainedAuthedApps; - Logger.Debug("AuthorizedApps was null"); ObtainAuthorizedApps(); return _obtainedAuthedApps!; } @@ -52,35 +49,45 @@ public User() { Email = ""; PasswordHash = ""; PermLevel = 0; - PermString = ""; - Language = "en"; + Language = "eng"; VerifiedEmail = false; TotpEnabled = false; - _originalAuthedApps = Array.Empty(); + _originalAuthedApps = []; StripeCustomerId = null; } public bool CheckPassword(string password) { - return PasswordHash == Utils.ToSHA256(password + (PasswordSalt ?? "")); + return PasswordHash == (password + (PasswordSalt ?? "")).Sha256Hash(); } + /// + /// Must be set before calling any method that touches storage (ObtainAuthorizedApps, + /// AuthorizeApp, EnsureStripeCustomer, RegisterChanges, UpdateAuthorizedApps). + /// Controllers should call user.WithRepos(userRepo) after loading the user. + /// + private IUserRepository? _userRepo; + + public User WithRepos(IUserRepository userRepo) { + _userRepo = userRepo; + return this; + } + public void ObtainAuthorizedApps() { - Program.StorageService!.GetAuthorizedApps(Id, out _originalAuthedApps); + _originalAuthedApps = _userRepo!.GetAuthorizedApps(Id); _obtainedAuthedApps = _originalAuthedApps; - Logger.Debug($"Obtained Authorized Apps for {Username}"); } public void AuthorizeApp(string appId, string scopes) { - AuthorizedApp app = new (appId, scopes); + AuthorizedApp app = new(appId, scopes); AuthorizeApp(app); } public void AuthorizeApp(AuthorizedApp app) { // If the app is already authorized delete it first foreach (AuthorizedApp authedApp in AuthorizedApps.Where(oa => oa.AppId == app.AppId)) { - Program.StorageService!.DeleteAuthorizedApp(Id, authedApp.AppId); + _userRepo!.DeleteAuthorizedApp(Id, authedApp.AppId); } - Program.StorageService!.AddAuthorizedApp(Id, app); + _userRepo!.AddAuthorizedApp(Id, app); } public void EnsureStripeCustomer() { @@ -98,16 +105,12 @@ public void EnsureStripeCustomer() { } public void RegisterChanges() { - Logger.Debug($"Registering changes to user: '{Username}' with id: '{Id}'"); - - Program.StorageService!.UpdateUser(this); - + _userRepo!.UpdateUser(this); UpdateAuthorizedApps(); } public void UpdateAuthorizedApps() { if (_originalAuthedApps == null || _obtainedAuthedApps == null) { - Logger.Debug("No changes to authorized apps were made"); return; } @@ -117,15 +120,13 @@ public void UpdateAuthorizedApps() { // Remove the removed apps foreach (AuthorizedApp app in removedApps) { - Program.StorageService!.DeleteAuthorizedApp(Id, app.AppId); + _userRepo!.DeleteAuthorizedApp(Id, app.AppId); } // Add the new apps foreach (AuthorizedApp app in addedApps) { - Program.StorageService!.AddAuthorizedApp(Id, app); + _userRepo!.AddAuthorizedApp(Id, app); } - - Logger.Debug("Added/Removed authed apps: " + addedApps.Length + "/" + removedApps.Length); } public bool ValidateTotp(string code) { diff --git a/Data/SerbleUtils.cs b/Data/SerbleUtils.cs index 3483a0a..4c30ef5 100644 --- a/Data/SerbleUtils.cs +++ b/Data/SerbleUtils.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; namespace SerbleAPI.Data; @@ -63,4 +64,21 @@ public static byte[][] ParseMda(this string str, Func parse) { } return arr; } + + public static string Sha256Hash(this string str) { + byte[] bytes = Encoding.UTF8.GetBytes(str); + byte[] hash = SHA256.HashData(bytes); + return hash.Base64Encode(); + } + + public static bool IsNull(this object? obj) { + return obj == null; + } + + public static T ThrowIfNull(this T? obj) where T : class { + if (obj == null) { + throw new Exception("Object is null"); + } + return obj; + } } \ No newline at end of file diff --git a/Data/ServicesStatusService.cs b/Data/ServicesStatusService.cs index 488b94f..96b4f9e 100644 --- a/Data/ServicesStatusService.cs +++ b/Data/ServicesStatusService.cs @@ -1,8 +1,9 @@ using System.Text.Json; -using GeneralPurposeLib; namespace SerbleAPI.Data; +// TODO: Have this be a service and DI +// TODO: Load products from regular config not custom one public static class ServicesStatusService { private static Service[]? _services; private static Service[]? _pingedServices; @@ -23,7 +24,6 @@ public static void Init() { if (!File.Exists(ConfigFileName)) { File.Create(ConfigFileName).Close(); File.WriteAllText(ConfigFileName, JsonSerializer.Serialize(DefaultConfig, SerializerOptions)); - Logger.Info("Config file created with default values"); _services = DefaultConfig; } string json = File.ReadAllText(ConfigFileName); @@ -31,11 +31,10 @@ public static void Init() { try { outputArray = JsonSerializer.Deserialize(json); if (outputArray == null) - throw new InvalidConfigException("Config file is not valid JSON"); + throw new Exception("Config file is not valid JSON"); } catch (Exception ex) { - Logger.Debug(ex); - throw new InvalidConfigException("Config file is invalid: " + ex.Message); + throw new Exception("Config file is invalid: " + ex.Message); } _services = outputArray; } @@ -81,7 +80,6 @@ private static async Task PingServices() { _pingedServices = pingedServices; _lastUpdated = DateTime.Now; - Logger.Debug("Services statuses updated"); } } diff --git a/Data/Storage/FileStorageService.cs b/Data/Storage/FileStorageService.cs deleted file mode 100644 index 1f60810..0000000 --- a/Data/Storage/FileStorageService.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System.Security; -using GeneralPurposeLib; -using Newtonsoft.Json; -using SerbleAPI.Data.Schemas; -using JsonException = System.Text.Json.JsonException; - -namespace SerbleAPI.Data.Storage; - -/* - * Dotnet's builtin JSON serializer does not seem to work with truples. - * So I'm using Newtonsoft.Json. - */ - -public class FileStorageService : IStorageService { - - private List _users = new(); - private List _apps = new(); - // (userid, appid, scopes) - private List<(string, AuthorizedApp)> _authorizations = new(); - private Dictionary _kv = new(); - private List<(string, string)> _ownedProducts = new(); // (userid, productid) - private List<(string, string, string)> _userNotes = new(); // (userid, noteid, note) - private List _passkeys = new(); - - public void Init() { - _users = new List(); - _apps = new List(); - _authorizations = new List<(string, AuthorizedApp)>(); - _kv = new Dictionary(); - _ownedProducts = new List<(string, string)>(); - _userNotes = new List<(string, string, string)>(); - _passkeys = new(); - - // Add dummy data - _users.Add(new User { - Id = Guid.NewGuid().ToString(), - Username = "admin", - PasswordHash = "e", - PermLevel = 0 - }); - OAuthApp app = new (_users.First().Id) { - Name = "Test App", - Description = "Test App" - }; - _apps.Add(app); - - Logger.Info("Loading data from data.json..."); - if (File.Exists("data.json")) { - string jsonData = File.ReadAllText("data.json"); - (List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>, List) data = - JsonConvert.DeserializeObject<(List, List, List<(string, AuthorizedApp)>, Dictionary, List<(string, string)>, List<(string, string, string)>, List)>(jsonData); - _users = data.Item1; - _apps = data.Item2; - _authorizations = data.Item3; - _kv = data.Item4; - _ownedProducts = data.Item5; - _userNotes = data.Item6; - _passkeys = data.Item7; - Logger.Info("Loaded data from data.json"); - } else { - Logger.Info("No data.json found, creating new data.json"); - File.WriteAllText("data.json", JsonConvert.SerializeObject((_users, _apps))); - Logger.Info("Created new data.json"); - } - Logger.Info("Data loaded"); - } - - public void Deinit() { - bool retry = true; - while (retry) { - retry = false; - bool error = false; - string errorText = "Unspecified error"; - Logger.Info("Saving data to data.json..."); - try { - File.WriteAllText("data.json", JsonConvert.SerializeObject((_users, _apps, _authorizations, _kv, _ownedProducts, _userNotes, _passkeys))); - Logger.Info("Saved data to data.json"); - } - catch (JsonException e) { - Logger.Error($"Failed to save data to data.json: The data failed to serialize: {e.Message}"); - Logger.Error("----- Data will not be saved -----"); - } catch (IOException e) { - errorText = $"Failed to save data to data.json (IOException): {e.Message}"; - error = true; - } catch (UnauthorizedAccessException) { - errorText = - $"Can't save data due to unauthorized access. Please make sure you have write access to: {Directory.GetCurrentDirectory()}"; - error = true; - } catch (SecurityException) { - errorText = - $"Can't save data due to unauthorized access. Please make sure you have access to: {Directory.GetCurrentDirectory()}"; - error = true; - } - - if (!error) continue; - Logger.Error(errorText); - string? input = null; - while (input == null) { - Console.WriteLine("Would you like to retry? (y/n)"); - input = Console.ReadLine(); - if (input == null) continue; - if (input.ToLower() == "y") { - retry = true; - Logger.Info("Reattempting to save data..."); - } - else { - Logger.Info("----- Data will not be saved -----"); - } - } - } - } - - public void AddUser(User userDetails, out User newUser) { - newUser = userDetails; - newUser.Id = Guid.NewGuid().ToString(); - _users.Add(newUser); - } - - public void GetUser(string userId, out User? user) { - user = _users.FirstOrDefault(u => u.Id == userId)!; - } - - public void UpdateUser(User userDetails) { - User? user = _users.FirstOrDefault(u => u.Id == userDetails.Id); - if (user == null) return; - int index = _users.IndexOf(user); - _users[index] = userDetails; - } - - public void DeleteUser(string userId) { - _users.RemoveAll(u => u.Id == userId); - } - - public void GetUserFromName(string userName, out User? user) { - user = _users.FirstOrDefault(u => u.Username == userName); - } - - public void CountUsers(out long userCount) { - userCount = (long) _users.Count; - } - - public void GetUserFromStripeCustomerId(string subscriptionId, out User? user) { - user = _users.FirstOrDefault(u => u.StripeCustomerId == subscriptionId); - } - - public void AddAuthorizedApp(string userId, AuthorizedApp app) { - _authorizations.Add((userId, app)); - } - - public void GetAuthorizedApps(string userId, out AuthorizedApp[] apps) { - try { - apps = _authorizations.Where(a => a.Item1 == userId).Select(a => a.Item2).ToArray(); - } - catch (ArgumentNullException) { - Logger.Error("Authorization was null"); - apps = Array.Empty(); - } - } - - public void DeleteAuthorizedApp(string userId, string appId) { - _authorizations.RemoveAll(a => a.Item1 == userId && a.Item2.AppId == appId); - } - - public void AddOAuthApp(OAuthApp app) { - _apps.Add(app); - } - - public void GetOAuthApp(string appId, out OAuthApp? app) { - app = _apps.FirstOrDefault(a => a.Id == appId); - } - - public void UpdateOAuthApp(OAuthApp app) { - OAuthApp? appToUpdate = _apps.FirstOrDefault(a => a.Id == app.Id); - if (appToUpdate == null) return; - int index = _apps.IndexOf(appToUpdate); - _apps[index] = app; - } - - public void DeleteOAuthApp(string appId) { - _apps.RemoveAll(a => a.Id == appId); - } - - public void GetOAuthAppsFromUser(string userId, out OAuthApp[] apps) { - apps = _apps.Where(a => a.OwnerId == userId).ToArray(); - } - - public void BasicKvSet(string key, string value) { - _kv[key] = value; - } - - public void BasicKvGet(string key, out string? value) { - value = _kv.TryGetValue(key, out string? v) ? v : null; - } - - public void GetOwnedProducts(string userId, out string[] products) { - products = _ownedProducts.Where(p => p.Item1 == userId).Select(p => p.Item2).ToArray(); - } - - public void AddOwnedProducts(string userId, string[] productId) { - _ownedProducts.AddRange(productId.Select(p => (userId, p))); - } - - public void RemoveOwnedProduct(string userId, string productId) { - _ownedProducts.RemoveAll(p => p.Item1 == userId && p.Item2 == productId); - } - - public void GetUserNotes(string userId, out string[] noteIds) { - noteIds = _userNotes.Where(n => n.Item1 == userId).Select(n => n.Item2).ToArray(); - } - - public void CreateUserNote(string userId, string noteId, string note) { - _userNotes.Add((userId, noteId, note)); - } - - public void UpdateUserNoteContent(string userId, string noteId, string note) { - _userNotes.RemoveAll(n => n.Item1 == userId && n.Item2 == noteId); - _userNotes.Add((userId, noteId, note)); - } - - public void GetUserNoteContent(string userId, string noteId, out string? content) { - (string, string, string)? r = _userNotes.FirstOrDefault(n => n.Item1 == userId && n.Item2 == noteId); - content = r?.Item3; - } - - public void DeleteUserNote(string userId, string noteId) { - _userNotes.RemoveAll(n => n.Item1 == userId && n.Item2 == noteId); - } - - public void CreatePasskey(SavedPasskey key) { - _passkeys.Add(key); - } - - public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { - keys = _passkeys.Where(k => k.OwnerId == userId).ToArray(); - } - - public void SetPasskeySignCount(byte[] credId, int val) { - SavedPasskey? key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); - if (key == null) return; - int index = _passkeys.IndexOf(key); - key.SignCount = (uint) val; - _passkeys[index] = key; - } - - public void GetUserIdFromPasskeyId(byte[] credId, out string? userId) { - SavedPasskey? key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); - userId = key?.OwnerId; - } - - public void GetPasskey(byte[] credId, out SavedPasskey? key) { - key = _passkeys.FirstOrDefault(k => k.CredentialId!.SequenceEqual(credId)); - } -} diff --git a/Data/Storage/IStorageService.cs b/Data/Storage/IStorageService.cs deleted file mode 100644 index 41ed7f3..0000000 --- a/Data/Storage/IStorageService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.Storage; - -public interface IStorageService { - public void Init(); - public void Deinit(); - - public void AddUser(User userDetails, out User newUser); - public void GetUser(string userId, out User? user); - public void UpdateUser(User userDetails); - public void DeleteUser(string userId); - public void GetUserFromName(string userName, out User? user); - public void CountUsers(out long userCount); - public void GetUserFromStripeCustomerId(string subscriptionId, out User? user); - - public void AddAuthorizedApp(string userId, AuthorizedApp app); - public void GetAuthorizedApps(string userId, out AuthorizedApp[] apps); - public void DeleteAuthorizedApp(string userId, string appId); - - public void AddOAuthApp(OAuthApp app); - public void GetOAuthApp(string appId, out OAuthApp? app); - public void UpdateOAuthApp(OAuthApp app); - public void DeleteOAuthApp(string appId); - public void GetOAuthAppsFromUser(string userId, out OAuthApp[] apps); - - public void BasicKvSet(string key, string value); - public void BasicKvGet(string key, out string? value); - - public void GetOwnedProducts(string userId, out string[] products); - public void AddOwnedProducts(string userId, string[] productIds); - public void RemoveOwnedProduct(string userId, string productId); - - public void GetUserNotes(string userId, out string[] noteIds); - public void CreateUserNote(string userId, string noteId, string note); - public void UpdateUserNoteContent(string userId, string noteId, string note); - public void GetUserNoteContent(string userId, string noteId, out string? content); - public void DeleteUserNote(string userId, string noteId); - - public void CreatePasskey(SavedPasskey key); - public void GetUsersPasskeys(string userId, out SavedPasskey[] keys); - public void SetPasskeySignCount(byte[] credId, int val); - public void GetUserIdFromPasskeyId(byte[] credId, out string? userId); - public void GetPasskey(byte[] credId, out SavedPasskey? key); -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlAuthorizedApps.cs b/Data/Storage/MySQL/MySqlAuthorizedApps.cs deleted file mode 100644 index 9dbd686..0000000 --- a/Data/Storage/MySQL/MySqlAuthorizedApps.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MySql.Data.MySqlClient; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - - public void AddAuthorizedApp(string userId, AuthorizedApp app) { - MySqlHelper.ExecuteNonQuery(_connectString, "INSERT INTO serblesite_user_authorized_apps (userid, appid, scopes) " + - "VALUES (@userid, @appid, @scopes)", - new MySqlParameter("@userid", userId), - new MySqlParameter("@appid", app.AppId), - new MySqlParameter("@scopes", app.Scopes)); - } - - public void GetAuthorizedApps(string userId, out AuthorizedApp[] apps) { - using MySqlDataReader reader2 = MySqlHelper.ExecuteReader(_connectString, "SELECT appid, scopes FROM serblesite_user_authorized_apps WHERE userid=@userid", - new MySqlParameter("@userid", userId)); - List authedApps = new (); - while (reader2.Read()) { - authedApps.Add(new AuthorizedApp( - reader2.GetString("appid"), - reader2.GetString("scopes"))); - } - reader2.Close(); - apps = authedApps.ToArray(); - } - - public void DeleteAuthorizedApp(string userId, string appId) { - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_user_authorized_apps WHERE userid=@userid AND appid=@appid", - new MySqlParameter("@userid", userId), - new MySqlParameter("@appid", appId)); - } - -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlKV.cs b/Data/Storage/MySQL/MySqlKV.cs deleted file mode 100644 index 341059c..0000000 --- a/Data/Storage/MySQL/MySqlKV.cs +++ /dev/null @@ -1,24 +0,0 @@ -using MySql.Data.MySqlClient; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - - public void BasicKvSet(string key, string value) { - MySqlHelper.ExecuteNonQuery(_connectString, "INSERT INTO serblesite_kv (k, v) VALUES (@key, @value) ON DUPLICATE KEY UPDATE v=@value", - new MySqlParameter("@key", key), - new MySqlParameter("@value", value)); - } - - public void BasicKvGet(string key, out string? value) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT v FROM serblesite_kv WHERE k=@key", - new MySqlParameter("@key", key)); - if (!reader.Read()) { - value = null; - return; - } - value = reader.GetString("v"); - reader.Close(); - } - -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlOAuthApps.cs b/Data/Storage/MySQL/MySqlOAuthApps.cs deleted file mode 100644 index a6e20d4..0000000 --- a/Data/Storage/MySQL/MySqlOAuthApps.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MySql.Data.MySqlClient; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - - public void AddOAuthApp(OAuthApp app) { - MySqlHelper.ExecuteNonQuery(_connectString, "INSERT INTO serblesite_apps (id, ownerid, name, description, clientsecret, redirecturi) " + - "VALUES (@id, @ownerid, @name, @description, @clientsecret, @redirecturi)", - new MySqlParameter("@id", app.Id), - new MySqlParameter("@ownerid", app.OwnerId), - new MySqlParameter("@name", app.Name), - new MySqlParameter("@description", app.Description), - new MySqlParameter("@clientsecret", app.ClientSecret), - new MySqlParameter("@redirecturi", app.RedirectUri)); - } - - public void GetOAuthApp(string appId, out OAuthApp? app) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_apps WHERE id=@id", - new MySqlParameter("@id", appId)); - if (!reader.Read()) { - app = null; - return; - } - app = new OAuthApp(reader.GetString("ownerid")) { - Id = reader.GetString("id"), - Name = reader.GetString("name"), - Description = reader.GetString("description"), - ClientSecret = reader.GetString("clientsecret"), - RedirectUri = reader.GetString("redirecturi") - }; - reader.Close(); - } - - public void UpdateOAuthApp(OAuthApp app) { - MySqlHelper.ExecuteNonQuery(_connectString, "UPDATE serblesite_apps SET name=@name, description=@description, clientsecret=@clientsecret, ownerid=@ownerid, redirecturi=@redirecturi WHERE id=@id", - new MySqlParameter("@name", app.Name), - new MySqlParameter("@ownerid", app.OwnerId), - new MySqlParameter("@description", app.Description), - new MySqlParameter("@clientsecret", app.ClientSecret), - new MySqlParameter("@id", app.Id), - new MySqlParameter("@redirecturi", app.RedirectUri)); - } - - public void DeleteOAuthApp(string appId) { - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_apps WHERE id=@id", - new MySqlParameter("@id", appId)); - } - - public void GetOAuthAppsFromUser(string userId, out OAuthApp[] apps) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_apps WHERE ownerid=@id", - new MySqlParameter("@id", userId)); - List appsList = new (); - while (reader.Read()) { - appsList.Add(new OAuthApp(userId) { - Id = reader.GetString("id"), - Name = reader.GetString("name"), - Description = reader.GetString("description"), - ClientSecret = reader.GetString("clientsecret"), - RedirectUri = reader.GetString("redirecturi") - }); - } - reader.Close(); - apps = appsList.ToArray(); - } - -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlPasskeys.cs b/Data/Storage/MySQL/MySqlPasskeys.cs deleted file mode 100644 index ce9df04..0000000 --- a/Data/Storage/MySQL/MySqlPasskeys.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Fido2NetLib.Objects; -using MySql.Data.MySqlClient; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - public void CreatePasskey(SavedPasskey key) { - using MySqlCommand cmd = new("INSERT INTO serblesite_user_passkeys" + - "(owner_id, name, credential_id, public_key, sign_count, aa_guid, attes_client_data_json, descriptor_type, descriptor_id, descriptor_transports, attes_format, transports, backup_eligible, backed_up, attes_object, device_public_keys) VALUES" + - "(@owner_id, @name, @credential_id, @public_key, @sign_count, @aa_guid, @attes_client_data_json, @descriptor_type, @descriptor_id, @descriptor_transports, @attes_format, @transports, @backup_eligible, @backed_up, @attes_object, @device_public_keys)", new MySqlConnection(_connectString)); - cmd.Parameters.AddWithValue("@owner_id", key.OwnerId); - cmd.Parameters.AddWithValue("@name", key.Name); - cmd.Parameters.AddWithValue("@credential_id", Convert.ToBase64String(key.CredentialId!)); - cmd.Parameters.AddWithValue("@public_key", Convert.ToBase64String(key.PublicKey!)); - cmd.Parameters.AddWithValue("@sign_count", key.SignCount); - cmd.Parameters.AddWithValue("@aa_guid", key.AaGuid!.Value.ToString()); - cmd.Parameters.AddWithValue("@attes_client_data_json", Convert.ToBase64String(key.AttestationClientDataJson!)); - cmd.Parameters.AddWithValue("@descriptor_type", key.Descriptor!.Type.GetIndex()); - cmd.Parameters.AddWithValue("@descriptor_id", Convert.ToBase64String(key.Descriptor!.Id)); - cmd.Parameters.AddWithValue("@descriptor_transports", key.Descriptor!.Transports); - cmd.Parameters.AddWithValue("@attes_format", key.AttestationFormat); - cmd.Parameters.AddWithValue("@transports", key.Transports!.ToBitmask()); - cmd.Parameters.AddWithValue("@backup_eligible", key.IsBackupEligible); - cmd.Parameters.AddWithValue("@backed_up", key.IsBackedUp); - cmd.Parameters.AddWithValue("@attes_object", Convert.ToBase64String(key.AttestationObject!)); - cmd.Parameters.AddWithValue("@device_public_keys", key.DevicePublicKeys!.StringifyMda()); - cmd.ExecuteNonQuery(); - } - - public void GetUsersPasskeys(string userId, out SavedPasskey[] keys) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_user_passkeys WHERE ownerid=@id", - new MySqlParameter("@id", userId)); - if (!reader.Read()) { - keys = []; - return; - } - - List keyList = []; - do { - keyList.Add(ReadPasskey(reader)); - } while (reader.Read()); - - keys = keyList.ToArray(); - } - - private static SavedPasskey ReadPasskey(MySqlDataReader reader) { - return new SavedPasskey { - OwnerId = reader.GetString("owner_id"), - Name = reader.GetString("name"), - CredentialId = Convert.FromBase64String(reader.GetString("credential_id")), - PublicKey = Convert.FromBase64String(reader.GetString("public_key")), - SignCount = reader.GetUInt32("sign_count"), - AaGuid = Guid.Parse(reader.GetString("aa_guid")), - AttestationClientDataJson = Convert.FromBase64String(reader.GetString("attes_client_data_json")), - Descriptor = new PublicKeyCredentialDescriptor( - SerbleUtils.EnumFromIndex(reader.GetInt32("descriptor_type")), - Convert.FromBase64String(reader.GetString("descriptor_id")), - SerbleUtils.FromBitmask(reader.GetInt32("descriptor_transports"))), - AttestationFormat = reader.GetString("attes_format"), - Transports = SerbleUtils.FromBitmask(reader.GetInt32("transports")), - IsBackupEligible = reader.GetBoolean("backup_eligible"), - IsBackedUp = reader.GetBoolean("backed_up"), - AttestationObject = Convert.FromBase64String(reader.GetString("attes_object")), - DevicePublicKeys = reader.GetString("device_public_keys").ParseMda(Convert.FromBase64String) - }; - } - - public void SetPasskeySignCount(byte[] credId, int val) { - throw new NotImplementedException(); - } - - public void GetUserIdFromPasskeyId(byte[] credId, out string? userId) { - throw new NotImplementedException(); - } - - public void GetPasskey(byte[] credId, out SavedPasskey? key) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_user_passkeys WHERE credential_id=@id", - new MySqlParameter("@id", Convert.ToBase64String(credId))); - if (!reader.Read()) { - key = null; - return; - } - - key = ReadPasskey(reader); - } -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlProducts.cs b/Data/Storage/MySQL/MySqlProducts.cs deleted file mode 100644 index 178a6cb..0000000 --- a/Data/Storage/MySQL/MySqlProducts.cs +++ /dev/null @@ -1,40 +0,0 @@ -using MySql.Data.MySqlClient; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - - public void GetOwnedProducts(string userId, out string[] products) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_owned_products WHERE user=@id", - new MySqlParameter("@id", userId)); - List productsList = new(); - - while (reader.Read()) { - productsList.Add(reader.GetString("product")); - } - - reader.Close(); - products = productsList.ToArray(); - } - - public void AddOwnedProducts(string userId, string[] productIds) { - string query = "INSERT INTO serblesite_owned_products (user, product) VALUES"; - List parameters = new(); - for (int i = 0; i < productIds.Length; i++) { - query += " (@user" + i + ", @product" + i + "),"; - parameters.Add(new MySqlParameter("@user" + i, userId)); - parameters.Add(new MySqlParameter("@product" + i, productIds[i])); - } - - // Remove the last comma - query = query[..^1]; - - MySqlHelper.ExecuteNonQuery(_connectString, query, parameters.ToArray()); - } - - public void RemoveOwnedProduct(string userId, string productId) { - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_owned_products WHERE user=@user AND product=@product", - new MySqlParameter("@user", userId), - new MySqlParameter("@product", productId)); - } -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlStorageService.cs b/Data/Storage/MySQL/MySqlStorageService.cs deleted file mode 100644 index c5c4ad1..0000000 --- a/Data/Storage/MySQL/MySqlStorageService.cs +++ /dev/null @@ -1,86 +0,0 @@ -using GeneralPurposeLib; -using MySql.Data.MySqlClient; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService : IStorageService { - private string? _connectString; - - public void Init() { - Logger.Info("Initialising MySQL..."); - _connectString = $"server={Program.Config!["mysql_ip"]};" + - $"userid={Program.Config["mysql_user"]};" + - $"password={Program.Config["mysql_password"]};" + - $"database={Program.Config["mysql_database"]}"; - Logger.Info("Creating tables..."); - CreateTables(); - Logger.Info("MySQL initialised."); - } - - public void Deinit() { - Logger.Info("MySQL de-initialised"); - } - - private void CreateTables() { - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_users( - id VARCHAR(64) primary key, - username VARCHAR(255), - email VARCHAR(64), - verifiedEmail BOOLEAN, - password VARCHAR(64), - permlevel INT, - permstring VARCHAR(64), - premiumLevel INT, - subscriptionId VARCHAR(32), - language VARCHAR(8), - totp_enabled BOOLEAN, - totp_secret VARCHAR(128), - password_salt VARCHAR(64))"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_authorized_apps( - userid VARCHAR(64), - appid VARCHAR(64), - scopes VARCHAR(128))"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_apps(" + - "ownerid VARCHAR(64), " + - "id VARCHAR(64), " + - "name VARCHAR(64), " + - "description VARCHAR(1024), " + - "clientsecret VARCHAR(64), " + - "redirecturi TEXT)"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_kv(" + - "k VARCHAR(64)," + - "v VARCHAR(1024))"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_owned_products(" + - "user VARCHAR(64), " + - "product VARCHAR(64), " + - "FOREIGN KEY (user) REFERENCES serblesite_users(id))"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_notes(" + - "user VARCHAR(64), " + - "noteid VARCHAR(64), " + - "note MEDIUMTEXT, " + - "FOREIGN KEY (user) REFERENCES serblesite_users(id))"); - SendMySqlStatement(@"CREATE TABLE IF NOT EXISTS serblesite_user_passkeys( - owner_id VARCHAR(64), - name VARCHAR(255), - credential_id TEXT, - public_key TEXT, - sign_count INT, - aa_guid VARCHAR(64), - attes_client_data_json TEXT, - descriptor_type INT, - descriptor_id TEXT, - descriptor_transports INT, - attes_format TEXT, - transports INT, - backup_eligible BOOL, - backed_up BOOL, - attes_object TEXT, - device_public_keys TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); - } - - private void SendMySqlStatement(string statement) { - MySqlHelper.ExecuteNonQuery(_connectString!, statement); - } - -} diff --git a/Data/Storage/MySQL/MySqlUsers.cs b/Data/Storage/MySQL/MySqlUsers.cs deleted file mode 100644 index ecfdca6..0000000 --- a/Data/Storage/MySQL/MySqlUsers.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Data; -using MySql.Data.MySqlClient; -using SerbleAPI.Data.Schemas; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - public void AddUser(User userDetails, out User newUser) { - userDetails.Id = Guid.NewGuid().ToString(); - MySqlHelper.ExecuteNonQuery(_connectString!, - "INSERT INTO serblesite_users(" + - "id, username, email, verifiedEmail, password, permlevel, permstring, subscriptionId, language, totp_enabled, totp_secret, password_salt) " + - "VALUES(@id, @username, @email, @verifiedEmail, @password, @permlevel, @permstring, @subscriptionId, @language, @topt_enabled, @topt_secret, @password_salt)", - new MySqlParameter("@id", userDetails.Id), - new MySqlParameter("@username", userDetails.Username), - new MySqlParameter("@email", userDetails.Email), - new MySqlParameter("@verifiedEmail", userDetails.VerifiedEmail), - new MySqlParameter("@password", userDetails.PasswordHash), - new MySqlParameter("@permlevel", userDetails.PermLevel), - new MySqlParameter("@permstring", userDetails.PermString), - new MySqlParameter("@subscriptionId", userDetails.StripeCustomerId), - new MySqlParameter("@language", userDetails.Language), - new MySqlParameter("@topt_enabled", userDetails.TotpEnabled), - new MySqlParameter("@topt_secret", userDetails.TotpSecret), - new MySqlParameter("@password_salt", userDetails.PasswordSalt)); - newUser = userDetails; - } - - public void GetUser(string userId, out User? user) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_users WHERE id=@id", - new MySqlParameter("@id", userId)); - if (!reader.Read()) { - user = null; - return; - } - string? subId = reader.IsDBNull("subscriptionId") ? null : reader.GetString("subscriptionId"); - string? language = reader.IsDBNull("language") ? null : reader.GetString("language"); - user = new User { - Id = reader.GetString("id"), - Username = reader.GetString("username"), - Email = reader.GetString("email"), - VerifiedEmail = reader.GetBoolean("verifiedEmail"), - PasswordHash = reader.GetString("password"), - PermLevel = reader.GetInt32("permlevel"), - PermString = reader.GetString("permstring"), - StripeCustomerId = subId, - Language = language, - TotpEnabled = reader.GetBoolean("totp_enabled"), - TotpSecret = reader.IsDBNull("totp_secret") ? null : reader.GetString("totp_secret"), - PasswordSalt = reader.IsDBNull("password_salt") ? null : reader.GetString("password_salt") - }; - - reader.Close(); - } - - public void GetUserFromStripeCustomerId(string subId, out User? user) { - user = null; - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT * FROM serblesite_users WHERE subscriptionId=@subId", - new MySqlParameter("@subId", subId)); - if (!reader.Read()) { - return; - } - string userId = reader.GetString("id"); - reader.Close(); - GetUser(userId, out user); - } - - public void UpdateUser(User userDetails) { - MySqlHelper.ExecuteNonQuery(_connectString, "UPDATE serblesite_users SET " + - "username=@username, " + - "email=@email, " + - "verifiedEmail=@verifiedEmail, " + - "password=@password, " + - "permlevel=@permlevel, " + - "permstring=@permstring, " + - "subscriptionId=@subscriptionId," + - "language=@language, " + - "totp_enabled=@totp_enabled, " + - "totp_secret=@totp_secret, " + - "password_salt=@password_salt " + - "WHERE id=@id", - new MySqlParameter("@username", userDetails.Username), - new MySqlParameter("@email", userDetails.Email), - new MySqlParameter("@verifiedEmail", userDetails.VerifiedEmail), - new MySqlParameter("@password", userDetails.PasswordHash), - new MySqlParameter("@permlevel", userDetails.PermLevel), - new MySqlParameter("@permstring", userDetails.PermString), - new MySqlParameter("@subscriptionId", userDetails.StripeCustomerId), - new MySqlParameter("@id", userDetails.Id), - new MySqlParameter("@language", userDetails.Language), - new MySqlParameter("@totp_enabled", userDetails.TotpEnabled), - new MySqlParameter("@totp_secret", userDetails.TotpSecret), - new MySqlParameter("@password_salt", userDetails.PasswordSalt)); - } - - public void DeleteUser(string userId) { - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_users WHERE id=@id", - new MySqlParameter("@id", userId)); - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_user_authorized_apps WHERE userid=@id", - new MySqlParameter("@id", userId)); - } - - public void GetUserFromName(string userName, out User? user) { - user = null; - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT id FROM serblesite_users WHERE username=@username", - new MySqlParameter("@username", userName)); - if (!reader.Read()) { - return; - } - string userId = reader.GetString("id"); - reader.Close(); - GetUser(userId, out user); - } - - public void CountUsers(out long userCount) { - userCount = (long) MySqlHelper.ExecuteScalar(_connectString, "SELECT COUNT(*) FROM serblesite_users"); - } -} \ No newline at end of file diff --git a/Data/Storage/MySQL/MySqlVault.cs b/Data/Storage/MySQL/MySqlVault.cs deleted file mode 100644 index 9a27778..0000000 --- a/Data/Storage/MySQL/MySqlVault.cs +++ /dev/null @@ -1,46 +0,0 @@ -using MySql.Data.MySqlClient; - -namespace SerbleAPI.Data.Storage.MySQL; - -public partial class MySqlStorageService { - public void GetUserNotes(string userId, out string[] noteIds) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT noteid FROM serblesite_user_notes WHERE user=@user", - new MySqlParameter("@user", userId)); - List ids = new(); - while (reader.Read()) { - ids.Add(reader.GetString("noteid")); - } - noteIds = ids.ToArray(); - } - - public void CreateUserNote(string userId, string noteId, string note) { - MySqlHelper.ExecuteNonQuery(_connectString, "INSERT INTO serblesite_user_notes(user, noteid, note) VALUES(@user, @noteid, @note)", - new MySqlParameter("@user", userId), - new MySqlParameter("@noteid", noteId), - new MySqlParameter("@note", note)); - } - - public void UpdateUserNoteContent(string userId, string noteId, string note) { - MySqlHelper.ExecuteNonQuery(_connectString, "UPDATE serblesite_user_notes SET note=@note WHERE user=@user AND noteid=@noteid", - new MySqlParameter("@user", userId), - new MySqlParameter("@noteid", noteId), - new MySqlParameter("@note", note)); - } - - public void GetUserNoteContent(string userId, string noteId, out string? content) { - using MySqlDataReader reader = MySqlHelper.ExecuteReader(_connectString, "SELECT note FROM serblesite_user_notes WHERE user=@user AND noteid=@noteid", - new MySqlParameter("@user", userId), - new MySqlParameter("@noteid", noteId)); - if (!reader.Read()) { - content = null; - return; - } - content = reader.GetString("note"); - } - - public void DeleteUserNote(string userId, string noteId) { - MySqlHelper.ExecuteNonQuery(_connectString, "DELETE FROM serblesite_user_notes WHERE user=@user AND noteid=@noteid", - new MySqlParameter("@user", userId), - new MySqlParameter("@noteid", noteId)); - } -} \ No newline at end of file diff --git a/Migrations/20260220115153_Initial.Designer.cs b/Migrations/20260220115153_Initial.Designer.cs new file mode 100644 index 0000000..bc72287 --- /dev/null +++ b/Migrations/20260220115153_Initial.Designer.cs @@ -0,0 +1,344 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SerbleAPI.Models; + +#nullable disable + +namespace SerbleAPI.Migrations +{ + [DbContext(typeof(SerbleDbContext))] + [Migration("20260220115153_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.24") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SerbleAPI.Models.DbApp", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ClientSecret") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("RedirectUri") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbKv", b => + { + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("varchar(2048)"); + + b.HasKey("Key"); + + b.ToTable("Kvs"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbOwnedProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Product") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("User") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("User"); + + b.ToTable("OwnedProducts"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUser", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + + b.Property("Password") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("PasswordSalt") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("PermLevel") + .HasColumnType("int"); + + b.Property("PremiumLevel") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TotpEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("TotpSecret") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("VerifiedEmail") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserAuthorizedApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("UserId"); + + b.ToTable("UserAuthorizedApps"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserNote", b => + { + b.Property("NoteId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("User") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("NoteId"); + + b.HasIndex("User"); + + b.ToTable("UserNotes"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserPasskey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AaGuid") + .HasColumnType("longtext"); + + b.Property("AttesClientDataJson") + .HasColumnType("longtext"); + + b.Property("AttesFormat") + .HasColumnType("longtext"); + + b.Property("AttesObject") + .HasColumnType("longtext"); + + b.Property("BackedUp") + .HasColumnType("tinyint(1)"); + + b.Property("BackupEligible") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasColumnType("longtext"); + + b.Property("DescriptorId") + .HasColumnType("longtext"); + + b.Property("DescriptorTransports") + .HasColumnType("int"); + + b.Property("DescriptorType") + .HasColumnType("int"); + + b.Property("DevicePublicKeys") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("SignCount") + .HasColumnType("int"); + + b.Property("Transports") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("UserPasskeys"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbApp", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "OwnerNavigation") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbOwnedProduct", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserAuthorizedApp", b => + { + b.HasOne("SerbleAPI.Models.DbApp", "AppNavigation") + .WithMany() + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppNavigation"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserNote", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserPasskey", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "OwnerNavigation") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OwnerNavigation"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260220115153_Initial.cs b/Migrations/20260220115153_Initial.cs new file mode 100644 index 0000000..444443a --- /dev/null +++ b/Migrations/20260220115153_Initial.cs @@ -0,0 +1,272 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SerbleAPI.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Kvs", + columns: table => new + { + Key = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Value = table.Column(type: "varchar(2048)", maxLength: 2048, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Kvs", x => x.Key); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Username = table.Column(type: "varchar(255)", maxLength: 255, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Email = table.Column(type: "varchar(255)", maxLength: 255, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Password = table.Column(type: "varchar(64)", maxLength: 64, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + PermLevel = table.Column(type: "int", nullable: false), + VerifiedEmail = table.Column(type: "tinyint(1)", nullable: false), + PremiumLevel = table.Column(type: "int", nullable: false), + SubscriptionId = table.Column(type: "varchar(64)", maxLength: 64, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Language = table.Column(type: "varchar(16)", maxLength: 16, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + TotpEnabled = table.Column(type: "tinyint(1)", nullable: false), + TotpSecret = table.Column(type: "varchar(128)", maxLength: 128, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + PasswordSalt = table.Column(type: "varchar(64)", maxLength: 64, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Apps", + columns: table => new + { + Id = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + OwnerId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "varchar(1024)", maxLength: 1024, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ClientSecret = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RedirectUri = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Apps", x => x.Id); + table.ForeignKey( + name: "FK_Apps_Users_OwnerId", + column: x => x.OwnerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "OwnedProducts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + User = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Product = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_OwnedProducts", x => x.Id); + table.ForeignKey( + name: "FK_OwnedProducts_Users_User", + column: x => x.User, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "UserNotes", + columns: table => new + { + NoteId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + User = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Note = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_UserNotes", x => x.NoteId); + table.ForeignKey( + name: "FK_UserNotes_Users_User", + column: x => x.User, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "UserPasskeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + OwnerId = table.Column(type: "varchar(64)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CredentialId = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + PublicKey = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + SignCount = table.Column(type: "int", nullable: true), + AaGuid = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + AttesClientDataJson = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + DescriptorType = table.Column(type: "int", nullable: true), + DescriptorId = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + DescriptorTransports = table.Column(type: "int", nullable: true), + AttesFormat = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Transports = table.Column(type: "int", nullable: true), + BackupEligible = table.Column(type: "tinyint(1)", nullable: true), + BackedUp = table.Column(type: "tinyint(1)", nullable: true), + AttesObject = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + DevicePublicKeys = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPasskeys", x => x.Id); + table.ForeignKey( + name: "FK_UserPasskeys_Users_OwnerId", + column: x => x.OwnerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "UserAuthorizedApps", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AppId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Scopes = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_UserAuthorizedApps", x => x.Id); + table.ForeignKey( + name: "FK_UserAuthorizedApps_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserAuthorizedApps_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Apps_OwnerId", + table: "Apps", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_OwnedProducts_User", + table: "OwnedProducts", + column: "User"); + + migrationBuilder.CreateIndex( + name: "IX_UserAuthorizedApps_AppId", + table: "UserAuthorizedApps", + column: "AppId"); + + migrationBuilder.CreateIndex( + name: "IX_UserAuthorizedApps_UserId", + table: "UserAuthorizedApps", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserNotes_User", + table: "UserNotes", + column: "User"); + + migrationBuilder.CreateIndex( + name: "IX_UserPasskeys_OwnerId", + table: "UserPasskeys", + column: "OwnerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Kvs"); + + migrationBuilder.DropTable( + name: "OwnedProducts"); + + migrationBuilder.DropTable( + name: "UserAuthorizedApps"); + + migrationBuilder.DropTable( + name: "UserNotes"); + + migrationBuilder.DropTable( + name: "UserPasskeys"); + + migrationBuilder.DropTable( + name: "Apps"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Migrations/SerbleDbContextModelSnapshot.cs b/Migrations/SerbleDbContextModelSnapshot.cs new file mode 100644 index 0000000..b6fdacc --- /dev/null +++ b/Migrations/SerbleDbContextModelSnapshot.cs @@ -0,0 +1,341 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SerbleAPI.Models; + +#nullable disable + +namespace SerbleAPI.Migrations +{ + [DbContext(typeof(SerbleDbContext))] + partial class SerbleDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.24") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SerbleAPI.Models.DbApp", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ClientSecret") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("RedirectUri") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbKv", b => + { + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("varchar(2048)"); + + b.HasKey("Key"); + + b.ToTable("Kvs"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbOwnedProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Product") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("User") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("User"); + + b.ToTable("OwnedProducts"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUser", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + + b.Property("Password") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("PasswordSalt") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("PermLevel") + .HasColumnType("int"); + + b.Property("PremiumLevel") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TotpEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("TotpSecret") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("VerifiedEmail") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserAuthorizedApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("UserId"); + + b.ToTable("UserAuthorizedApps"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserNote", b => + { + b.Property("NoteId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("User") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("NoteId"); + + b.HasIndex("User"); + + b.ToTable("UserNotes"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserPasskey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AaGuid") + .HasColumnType("longtext"); + + b.Property("AttesClientDataJson") + .HasColumnType("longtext"); + + b.Property("AttesFormat") + .HasColumnType("longtext"); + + b.Property("AttesObject") + .HasColumnType("longtext"); + + b.Property("BackedUp") + .HasColumnType("tinyint(1)"); + + b.Property("BackupEligible") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasColumnType("longtext"); + + b.Property("DescriptorId") + .HasColumnType("longtext"); + + b.Property("DescriptorTransports") + .HasColumnType("int"); + + b.Property("DescriptorType") + .HasColumnType("int"); + + b.Property("DevicePublicKeys") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("SignCount") + .HasColumnType("int"); + + b.Property("Transports") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("UserPasskeys"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbApp", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "OwnerNavigation") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbOwnedProduct", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserAuthorizedApp", b => + { + b.HasOne("SerbleAPI.Models.DbApp", "AppNavigation") + .WithMany() + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppNavigation"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserNote", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "UserNavigation") + .WithMany() + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("SerbleAPI.Models.DbUserPasskey", b => + { + b.HasOne("SerbleAPI.Models.DbUser", "OwnerNavigation") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OwnerNavigation"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Models/DbApp.cs b/Models/DbApp.cs new file mode 100644 index 0000000..11bc4d0 --- /dev/null +++ b/Models/DbApp.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SerbleAPI.Models; + +public class DbApp { + [Key] + [StringLength(64)] + public string Id { get; set; } = null!; + + [StringLength(64)] + [ForeignKey(nameof(OwnerNavigation))] + public string OwnerId { get; set; } = null!; + + [StringLength(64)] + public string Name { get; set; } = null!; + + [StringLength(1024)] + public string Description { get; set; } = null!; + + [StringLength(64)] + public string ClientSecret { get; set; } = null!; + + public string RedirectUri { get; set; } = null!; + + // navigation properties + public DbUser OwnerNavigation { get; set; } = null!; +} diff --git a/Models/DbKv.cs b/Models/DbKv.cs new file mode 100644 index 0000000..900d3f8 --- /dev/null +++ b/Models/DbKv.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace SerbleAPI.Models; + +public class DbKv { + [Key] + [StringLength(64)] + public string Key { get; set; } = null!; + + [StringLength(2048)] + public string Value { get; set; } = null!; +} diff --git a/Models/DbOwnedProduct.cs b/Models/DbOwnedProduct.cs new file mode 100644 index 0000000..4482bfc --- /dev/null +++ b/Models/DbOwnedProduct.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SerbleAPI.Models; + +public class DbOwnedProduct { + [Key] + public int Id { get; set; } + + [StringLength(64)] + [ForeignKey(nameof(UserNavigation))] + public string User { get; set; } = null!; + + [StringLength(64)] + public string Product { get; set; } = null!; + + // navigation properties + public DbUser UserNavigation { get; set; } = null!; +} diff --git a/Models/DbUser.cs b/Models/DbUser.cs new file mode 100644 index 0000000..289a5c4 --- /dev/null +++ b/Models/DbUser.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace SerbleAPI.Models; + +public class DbUser { + [Key] + [StringLength(64)] + public string Id { get; set; } = null!; + + [StringLength(255)] + public string Username { get; set; } = null!; + + [StringLength(255)] // by standard, emails can be up to 254 characters long + public string? Email { get; set; } + + [StringLength(64)] + public string? Password { get; set; } + + public int PermLevel { get; set; } + + public bool VerifiedEmail { get; set; } + + public int PremiumLevel { get; set; } + + /// + /// The stripe subscription id + /// + [StringLength(64)] + public string? SubscriptionId { get; set; } + + [StringLength(16)] + public string? Language { get; set; } + + public bool TotpEnabled { get; set; } + + [StringLength(128)] + public string? TotpSecret { get; set; } + + [StringLength(64)] + public string? PasswordSalt { get; set; } +} diff --git a/Models/DbUserAuthorizedApp.cs b/Models/DbUserAuthorizedApp.cs new file mode 100644 index 0000000..aa9d043 --- /dev/null +++ b/Models/DbUserAuthorizedApp.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SerbleAPI.Models; + +public class DbUserAuthorizedApp { + [Key] + public int Id { get; set; } + + [ForeignKey(nameof(UserNavigation))] + [StringLength(64)] + public string UserId { get; set; } = null!; + + [ForeignKey(nameof(AppNavigation))] + [StringLength(64)] + public string AppId { get; set; } = null!; + + [StringLength(128)] + public string Scopes { get; set; } = null!; + + // navigation properties + public DbUser UserNavigation { get; set; } = null!; + public DbApp AppNavigation { get; set; } = null!; +} diff --git a/Models/DbUserNote.cs b/Models/DbUserNote.cs new file mode 100644 index 0000000..aae6066 --- /dev/null +++ b/Models/DbUserNote.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SerbleAPI.Models; + +public class DbUserNote { + [StringLength(64)] + [ForeignKey(nameof(UserNavigation))] + public string User { get; set; } = null!; + + [Key] + [StringLength(64)] + public string NoteId { get; set; } = null!; + + public string Note { get; set; } = null!; + + // navigation properties + public DbUser UserNavigation { get; set; } = null!; +} diff --git a/Models/DbUserPasskey.cs b/Models/DbUserPasskey.cs new file mode 100644 index 0000000..3a50c4b --- /dev/null +++ b/Models/DbUserPasskey.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SerbleAPI.Models; + +public class DbUserPasskey { + [Key] + public int Id { get; set; } + + [ForeignKey(nameof(OwnerNavigation))] + public string OwnerId { get; set; } = null!; + + public string? Name { get; set; } + + public string? CredentialId { get; set; } + + public string? PublicKey { get; set; } + + public int? SignCount { get; set; } + + public string? AaGuid { get; set; } + + public string? AttesClientDataJson { get; set; } + + public int? DescriptorType { get; set; } + + public string? DescriptorId { get; set; } + + public int? DescriptorTransports { get; set; } + + public string? AttesFormat { get; set; } + + public int? Transports { get; set; } + + public bool? BackupEligible { get; set; } + + public bool? BackedUp { get; set; } + + public string? AttesObject { get; set; } + + public string? DevicePublicKeys { get; set; } + + public DateTime? CreatedAt { get; set; } + + // navigation properties + public DbUser OwnerNavigation { get; set; } = null!; +} diff --git a/Models/SerbleDbContext.cs b/Models/SerbleDbContext.cs new file mode 100644 index 0000000..1f6e6f7 --- /dev/null +++ b/Models/SerbleDbContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace SerbleAPI.Models; + +public class SerbleDbContext : DbContext { + + public SerbleDbContext() { + + } + + public SerbleDbContext(DbContextOptions options) : base(options) { + + } + + public virtual DbSet Apps { get; set; } + public virtual DbSet Kvs { get; set; } + public virtual DbSet OwnedProducts { get; set; } + public virtual DbSet Users { get; set; } + public virtual DbSet UserAuthorizedApps { get; set; } + public virtual DbSet UserNotes { get; set; } + public virtual DbSet UserPasskeys { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + + } +} diff --git a/Program.cs b/Program.cs index abc64d6..a2994fd 100644 --- a/Program.cs +++ b/Program.cs @@ -1,311 +1,153 @@ -using System.Security; -using Fido2NetLib; -using GeneralPurposeLib; +using Microsoft.EntityFrameworkCore; +using SerbleAPI.API; +using SerbleAPI.Authentication; +using SerbleAPI.Config; using SerbleAPI.Data; using SerbleAPI.Data.Raw; -using SerbleAPI.Data.Storage; -using SerbleAPI.Data.Storage.MySQL; +using SerbleAPI.Models; +using SerbleAPI.Repositories; +using SerbleAPI.Repositories.Impl; +using SerbleAPI.Services; +using SerbleAPI.Services.Impl; using Stripe; -using File = System.IO.File; -using LogLevel = GeneralPurposeLib.LogLevel; -using UnauthorizedAccessException = System.UnauthorizedAccessException; +using TokenService = SerbleAPI.Services.Impl.TokenService; namespace SerbleAPI; public static class Program { - - private static ConfigManager? _configManager; - private static readonly Dictionary ConfigDefaults = new() { - { "bind_url", "http://*:5000" }, - { "storage_service", "file" }, - { "http_authorization_token", "my very secure auth token" }, - { "http_url", "https://myverysecurestoragebackend.io/" }, - { "my_host" , "https://theplacewherethisappisaccessable.com/" }, - { "my_domain", "serble.net" }, - { "fido_origins", "https://serble.net;https://www.serble.net;https://serble" }, - { "token_issuer", "CoPokBl" }, - { "token_audience", "Privileged Users" }, - { "token_secret" , Guid.NewGuid().ToString() }, - { "mysql_ip", "mysql.example.com" }, - { "mysql_user", "coolperson" }, - { "mysql_password", "myverysecurepassword" }, - { "mysql_database", "serble" }, - { "smtp_username", "system@serble.net" }, - { "smtp_password", "very secure password" }, - { "smtp_host", "smtp.serble.net" }, - { "smtp_port", "587" }, - { "EmailAddress_System", "system@serble.net" }, - { "EmailAddress_Newsletter", "newsletter@serble.net" }, - { "admin_contact_email", "admin@serble.net" }, - { "google_recaptcha_site_key", "" }, - { "google_recaptcha_secret_key", "" }, - { "turnstile_captcha_secret_key", "" }, - { "logging_level", "1" }, - { "website_url", "https://serble.net" }, - { "testing", "true" }, - { "stripe_key", "stripe_api_key" }, - { "stripe_test_key", "stripe_api_key" }, - { "stripe_webhook_secret", "we_**************" }, - { "stripe_testing_webhook_secret", "we_**************" }, - { "stripe_premium_sub_id", "SerblePremiumPriceID" }, - { "stripe_testing_premium_sub_id", "SerblePremiumPriceID" }, - { "give_products_to_non_admins_while_testing", "false" }, - { "fido_mds_cache_dir", "./mdscache" }, - { "server_icon", "https://serble.net/assets/images/icon.png" } - }; - public static Dictionary? Config; - public static IStorageService? StorageService; - public static bool RunApp = true; - public static bool RestartApp = false; - public static bool RestartAppOnce; - public static bool Testing; - - private static int Main(string[] args) { + private static bool _runApp = true; - try { - Logger.Init(LogLevel.Debug); - } - catch (Exception e) { - Console.WriteLine(e); - Console.WriteLine("Failed to initialize logger"); - return 1; - } - - int stopCode = 0; - bool firstRun = true; - try { - while (RestartApp || RestartAppOnce || firstRun) { - if (firstRun) { firstRun = false; } - if (RestartAppOnce) { RestartAppOnce = false; } - stopCode = Run(args); - Logger.Warn("Application stopped with code " + stopCode); - } - return stopCode; - } - catch (Exception e) { - Logger.Error(e); - Logger.Error("The application has crashed due to an unhandled exception."); - stopCode = 1; - } - - try { - Logger.WaitFlush(); - } - catch (Exception e) { - Console.WriteLine(e); - Console.WriteLine("Failed to flush logger, writing error to logfail.log"); - try { - File.WriteAllText("logfail.log", e.ToString()); - } - catch (UnauthorizedAccessException) { - Console.WriteLine("Failed to write logfail.log due to access denied error."); - return 1; - } - catch (SecurityException) { - Console.WriteLine("Failed to write logfail.log due to access denied error."); - return 1; - } - catch (IOException ioException) { - Console.WriteLine(ioException); - Console.WriteLine("Failed to write logfail.log due to IO Error."); - return 1; - } - catch (Exception writeFailEx) { - Console.WriteLine(writeFailEx); - Console.WriteLine("Failed to write logfail.log due to an unknown error."); - return 1; - } - } - return stopCode; + private static int Main(string[] args) { + return Run(args); } private static int Run(string[] args) { - - // Intercepts Ctrl+C and Ctrl+Break - Console.CancelKeyPress += (sender, eventArgs) => { - Logger.Info("Received cancel signal, shutting down..."); - RunApp = false; + Console.CancelKeyPress += (_, eventArgs) => { + Console.WriteLine("Shutting down..."); + _runApp = false; eventArgs.Cancel = true; }; + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - // Config - Logger.Info("Loading config..."); - _configManager = new ConfigManager("config.json", ConfigDefaults); - Config = _configManager.LoadConfig(); - Testing = Config["testing"] == "true"; - StripeConfiguration.ApiKey = Testing ? Config["stripe_test_key"] : Config["stripe_key"]; - Logger.Info("Config loaded."); + // Startup time config info + StripeSettings? stripeSettings = builder.Configuration.GetSection("Stripe").Get(); + ApiSettings? apiSettings = builder.Configuration.GetSection("Api").Get(); + PasskeySettings? passkeySettings = builder.Configuration.GetSection("Passkey").Get(); + if (stripeSettings == null || apiSettings == null || passkeySettings == null) { + throw new Exception("Stripe or API or Passkey settings not found in configuration"); + } + StripeConfiguration.ApiKey = stripeSettings.ApiKey; ProductManager.Load(); + RawDataManager.LoadRawData(); - // Loglevel - switch (Config["logging_level"]) { - case "0": - Logger.LoggingLevel = LogLevel.None; - break; - case "1": - Logger.LoggingLevel = LogLevel.Debug; - break; - case "2": - Logger.LoggingLevel = LogLevel.Info; - break; - case "3": - Logger.LoggingLevel = LogLevel.Warn; - break; - case "4": - Logger.LoggingLevel = LogLevel.Error; - break; - default: - Logger.Error("Invalid logging level in config, defaulting to Info."); - Logger.LoggingLevel = LogLevel.Info; - break; - } - - // Storage service - try { - StorageService = Config["storage_service"].ToLower() switch { - "file" => new FileStorageService(), - "mysql" => new MySqlStorageService(), - _ => throw new Exception("Unknown storage service") - }; - } - catch (Exception e) { - if (e.Message != "Unknown storage service") throw; - Logger.Error("Invalid storage service specified in config."); - return 1; - } - - // Init storage - Logger.Info("Initializing storage..."); - try { - StorageService.Init(); - } - catch (Exception e) { - Logger.Error("Failed to initialize storage"); - Logger.Error(e); - return 1; - } - - if (args.Length != 0) { - - switch (args[0]) { - - default: - Console.WriteLine("Unknown command"); - return 1; - - } - } + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Api")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Stripe")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Passkey")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Email")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("ReCaptcha")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Jwt")); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Turnstile")); - // Load Raw Data - Logger.Info("Loading raw data..."); - try { - RawDataManager.LoadRawData(); - Logger.Info("Raw data loaded."); - } - catch (Exception e) { - Logger.Error("Failed to load raw data"); - Logger.Error(e); - return 1; - } - - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + builder.Services.AddControllers(); + builder.Services.AddSwaggerGen(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddDistributedMemoryCache(); + builder.Services.AddMemoryCache(); + builder.Services.AddSession(options => { + options.IdleTimeout = TimeSpan.FromMinutes(5); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + }); + + builder.Services.AddDbContext(options => + options.UseMySql(builder.Configuration.GetConnectionString("MySql"), + ServerVersion.AutoDetect(builder.Configuration.GetConnectionString("MySql")))); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // db repos + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Authentication + builder.Services.AddAuthentication(SerbleAuthenticationHandler.SchemeName) + .AddScheme( + SerbleAuthenticationHandler.SchemeName, _ => { }); + + // Authorisation + // Register one policy per scope: [Authorize(Policy = "Scope:Vault")] etc. + // Also a UserOnly policy that blocks app tokens. + builder.Services.AddAuthorization(opts => { + foreach (ScopeHandler.ScopesEnum scope in Enum.GetValues()) { + ScopeHandler.ScopesEnum captured = scope; + opts.AddPolicy($"Scope:{captured}", p => + p.RequireAuthenticatedUser() + .RequireAssertion(ctx => ctx.User.HasScope(captured))); + } + opts.AddPolicy("UserOnly", p => + p.RequireAuthenticatedUser() + .RequireAssertion(ctx => ctx.User.IsUser())); + }); - // Add services to the container. - try { - builder.Services.AddControllers(); - builder.Services.AddSwaggerGen(); - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddDistributedMemoryCache(); - builder.Services.AddMemoryCache(); - builder.WebHost.UseUrls(Config["bind_url"]); - - builder.Services.AddFido2(options => { - options.ServerDomain = Config["my_domain"]; - options.ServerName = "FIDO2 Test"; - options.Origins = Config["fido_origins"].Split(';').ToHashSet(); - options.TimestampDriftTolerance = 1000 * 60 * 5; - options.ServerIcon = Config["server_icon"]; - }).AddCachedMetadataService(config => { - config.AddFidoMetadataRepository(_ => { - - }); + builder.WebHost.UseUrls(apiSettings.BindUrl); // move IP binding to config because I hate launchSettings.json + + builder.Services.AddFido2(options => { + options.ServerDomain = passkeySettings.RelyingPartyId; + options.ServerName = passkeySettings.RelyingPartyName; + options.Origins = passkeySettings.AllowedOrigins.ToHashSet(); + options.TimestampDriftTolerance = 1000 * 60 * 5; + options.ServerIcon = passkeySettings.ServerIconUrl; + }).AddCachedMetadataService(config => { + config.AddFidoMetadataRepository(_ => { + }); - } - catch (Exception e) { - Logger.Error(e); - Logger.Error("Failed to add services"); - return 1; - } + }); // Init services - Logger.Info("Initializing services..."); - try { - ServicesStatusService.Init(); - } - catch (Exception e) { - Logger.Error("Failed to initialize services"); - Logger.Error(e); - return 1; - } - - WebApplication app; - try { - app = builder.Build(); - - if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Error"); - app.UseHsts(); - } - - app.MapControllers(); - app.UseSwagger(); - app.UseSwaggerUI(); - } - catch (Exception e) { - Logger.Error(e); - Logger.Error("Failed to initialize application"); - return 1; - } + ServicesStatusService.Init(); - bool didError = false; - try { - CancellationTokenSource tokenSource = new(); - CancellationToken cancellationToken = tokenSource.Token; - Task appTask = app.RunAsync(cancellationToken); - Logger.Info("Application started"); + WebApplication app = builder.Build(); - while (RunApp) { - if (appTask.IsCompleted) { - Logger.Info("Application execution finished"); - break; - } - Thread.Sleep(100); - } - tokenSource.Cancel(); - Logger.Info("Attempting to stop application (Will abort after 10 seconds)"); - bool successfulStop = appTask.Wait(new TimeSpan(0, 0, 10)); - Logger.Info(!successfulStop - ? "Application stop timed out, completing execution" - : "Server stopped with no errors."); - } - catch (Exception e) { - Logger.Error(e); - Logger.Error("Server stopped with error."); - didError = true; - } + if (!app.Environment.IsDevelopment()) { + app.UseExceptionHandler("/Error"); + app.UseHsts(); + } + + // Middleware order: cors/options -> redirects -> session -> auth -> controllers + app.UseMiddleware(); + app.UseMiddleware(); + app.UseSession(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapControllers(); + app.UseSwagger(); + app.UseSwaggerUI(); + + CancellationTokenSource tokenSource = new(); + CancellationToken cancellationToken = tokenSource.Token; + Task appTask = app.RunAsync(cancellationToken); - // Shutdown storage - Logger.Info("Shutting down storage..."); - try { - StorageService.Deinit(); - } - catch (Exception e) { - Logger.Error("Failed to shutdown storage"); - Logger.Error(e); + while (_runApp) { + if (appTask.IsCompleted) { + break; + } + Thread.Sleep(100); } + tokenSource.Cancel(); + Console.WriteLine("Attempting to stop application (Will abort after 10 seconds)"); + appTask.Wait(new TimeSpan(0, 0, 10)); - return didError ? 1 : 0; + return 0; } } \ No newline at end of file diff --git a/Repositories/IAppRepository.cs b/Repositories/IAppRepository.cs new file mode 100644 index 0000000..073a40e --- /dev/null +++ b/Repositories/IAppRepository.cs @@ -0,0 +1,11 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Repositories; + +public interface IAppRepository { + OAuthApp? GetOAuthApp(string appId); + OAuthApp[] GetOAuthAppsFromUser(string userId); + void AddOAuthApp(OAuthApp app); + void UpdateOAuthApp(OAuthApp app); + void DeleteOAuthApp(string appId); +} diff --git a/Repositories/IKvRepository.cs b/Repositories/IKvRepository.cs new file mode 100644 index 0000000..8436fe9 --- /dev/null +++ b/Repositories/IKvRepository.cs @@ -0,0 +1,6 @@ +namespace SerbleAPI.Repositories; + +public interface IKvRepository { + void Set(string key, string value); + string? Get(string key); +} diff --git a/Repositories/INoteRepository.cs b/Repositories/INoteRepository.cs new file mode 100644 index 0000000..29ec831 --- /dev/null +++ b/Repositories/INoteRepository.cs @@ -0,0 +1,9 @@ +namespace SerbleAPI.Repositories; + +public interface INoteRepository { + string[] GetUserNotes(string userId); + void CreateUserNote(string userId, string noteId, string content); + void UpdateUserNoteContent(string userId, string noteId, string content); + string? GetUserNoteContent(string userId, string noteId); + void DeleteUserNote(string userId, string noteId); +} diff --git a/Repositories/IPasskeyRepository.cs b/Repositories/IPasskeyRepository.cs new file mode 100644 index 0000000..585aa14 --- /dev/null +++ b/Repositories/IPasskeyRepository.cs @@ -0,0 +1,13 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Repositories; + +public interface IPasskeyRepository { + void CreatePasskey(SavedPasskey key); + SavedPasskey[] GetUsersPasskeys(string userId); + SavedPasskey? GetPasskey(byte[] credId); + string? GetUserIdFromPasskeyId(byte[] credId); + void SetPasskeySignCount(byte[] credId, int val); + void DeletePasskey(byte[] credId); + void UpdatePasskeyDevicePublicKeys(byte[] credId, byte[][] devicePublicKeys); +} diff --git a/Repositories/IProductRepository.cs b/Repositories/IProductRepository.cs new file mode 100644 index 0000000..dcc7bee --- /dev/null +++ b/Repositories/IProductRepository.cs @@ -0,0 +1,7 @@ +namespace SerbleAPI.Repositories; + +public interface IProductRepository { + string[] GetOwnedProducts(string userId); + void AddOwnedProducts(string userId, string[] productIds); + void RemoveOwnedProduct(string userId, string productId); +} diff --git a/Repositories/IUserRepository.cs b/Repositories/IUserRepository.cs new file mode 100644 index 0000000..dab8242 --- /dev/null +++ b/Repositories/IUserRepository.cs @@ -0,0 +1,17 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Repositories; + +public interface IUserRepository { + User? GetUser(string userId); + User? GetUserFromName(string userName); + User? GetUserFromStripeCustomerId(string customerId); + void AddUser(User user, out User newUser); + void UpdateUser(User user); + void DeleteUser(string userId); + long CountUsers(); + + void AddAuthorizedApp(string userId, AuthorizedApp app); + AuthorizedApp[] GetAuthorizedApps(string userId); + void DeleteAuthorizedApp(string userId, string appId); +} diff --git a/Repositories/Impl/AppRepository.cs b/Repositories/Impl/AppRepository.cs new file mode 100644 index 0000000..747d5eb --- /dev/null +++ b/Repositories/Impl/AppRepository.cs @@ -0,0 +1,57 @@ +using SerbleAPI.Data.Schemas; +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class AppRepository(SerbleDbContext db) : IAppRepository { + + private static OAuthApp Map(DbApp r) => new(r.OwnerId!) { + Id = r.Id!, + Name = r.Name ?? "", + Description = r.Description ?? "", + ClientSecret = r.ClientSecret ?? "", + RedirectUri = r.RedirectUri ?? "" + }; + + public OAuthApp? GetOAuthApp(string appId) { + DbApp? row = db.Apps.FirstOrDefault(a => a.Id == appId); + return row == null ? null : Map(row); + } + + public OAuthApp[] GetOAuthAppsFromUser(string userId) => + db.Apps + .Where(a => a.OwnerId == userId) + .AsEnumerable() + .Select(Map) + .ToArray(); + + public void AddOAuthApp(OAuthApp app) { + db.Apps.Add(new DbApp { + Id = app.Id, + OwnerId = app.OwnerId, + Name = app.Name, + Description = app.Description, + ClientSecret = app.ClientSecret, + RedirectUri = app.RedirectUri + }); + db.SaveChanges(); + } + + public void UpdateOAuthApp(OAuthApp app) { + DbApp? row = db.Apps.FirstOrDefault(a => a.Id == app.Id); + if (row == null) return; + row.OwnerId = app.OwnerId; + row.Name = app.Name; + row.Description = app.Description; + row.ClientSecret = app.ClientSecret; + row.RedirectUri = app.RedirectUri; + db.SaveChanges(); + } + + public void DeleteOAuthApp(string appId) { + DbApp? row = db.Apps.FirstOrDefault(a => a.Id == appId); + if (row == null) return; + db.Apps.Remove(row); + db.SaveChanges(); + } +} diff --git a/Repositories/Impl/KvRepository.cs b/Repositories/Impl/KvRepository.cs new file mode 100644 index 0000000..95c5c9d --- /dev/null +++ b/Repositories/Impl/KvRepository.cs @@ -0,0 +1,23 @@ +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class KvRepository(SerbleDbContext db) : IKvRepository { + + public void Set(string key, string value) { + DbKv? row = db.Kvs.FirstOrDefault(k => k.Key == key); + if (row == null) { + db.Kvs.Add(new DbKv { Key = key, Value = value }); + } + else { + row.Value = value; + } + db.SaveChanges(); + } + + public string? Get(string key) => + db.Kvs + .Where(k => k.Key == key) + .Select(k => k.Value) + .FirstOrDefault(); +} diff --git a/Repositories/Impl/NoteRepository.cs b/Repositories/Impl/NoteRepository.cs new file mode 100644 index 0000000..af81de2 --- /dev/null +++ b/Repositories/Impl/NoteRepository.cs @@ -0,0 +1,37 @@ +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class NoteRepository(SerbleDbContext db) : INoteRepository { + + public string[] GetUserNotes(string userId) => + db.UserNotes + .Where(n => n.User == userId) + .Select(n => n.NoteId!) + .ToArray(); + + public void CreateUserNote(string userId, string noteId, string content) { + db.UserNotes.Add(new DbUserNote { User = userId, NoteId = noteId, Note = content }); + db.SaveChanges(); + } + + public void UpdateUserNoteContent(string userId, string noteId, string content) { + DbUserNote? row = db.UserNotes.FirstOrDefault(n => n.User == userId && n.NoteId == noteId); + if (row == null) return; + row.Note = content; + db.SaveChanges(); + } + + public string? GetUserNoteContent(string userId, string noteId) => + db.UserNotes + .Where(n => n.User == userId && n.NoteId == noteId) + .Select(n => n.Note) + .FirstOrDefault(); + + public void DeleteUserNote(string userId, string noteId) { + DbUserNote? row = db.UserNotes.FirstOrDefault(n => n.User == userId && n.NoteId == noteId); + if (row == null) return; + db.UserNotes.Remove(row); + db.SaveChanges(); + } +} diff --git a/Repositories/Impl/PasskeyRepository.cs b/Repositories/Impl/PasskeyRepository.cs new file mode 100644 index 0000000..1f3d168 --- /dev/null +++ b/Repositories/Impl/PasskeyRepository.cs @@ -0,0 +1,101 @@ +using Fido2NetLib.Objects; +using SerbleAPI.Data; +using SerbleAPI.Data.Schemas; +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class PasskeyRepository(SerbleDbContext db) : IPasskeyRepository { + + private static SavedPasskey Map(DbUserPasskey r) => new() { + OwnerId = r.OwnerId, + Name = r.Name, + CredentialId = Convert.FromBase64String(r.CredentialId!), + PublicKey = Convert.FromBase64String(r.PublicKey!), + SignCount = (uint)(r.SignCount ?? 0), + AaGuid = Guid.Parse(r.AaGuid!), + AttestationClientDataJson = Convert.FromBase64String(r.AttesClientDataJson!), + Descriptor = new PublicKeyCredentialDescriptor( + SerbleUtils.EnumFromIndex(r.DescriptorType ?? 0), + Convert.FromBase64String(r.DescriptorId!), + SerbleUtils.FromBitmask(r.DescriptorTransports ?? 0)), + AttestationFormat = r.AttesFormat, + Transports = SerbleUtils.FromBitmask(r.Transports ?? 0), + IsBackupEligible = r.BackupEligible ?? false, + IsBackedUp = r.BackedUp ?? false, + AttestationObject = Convert.FromBase64String(r.AttesObject!), + DevicePublicKeys = string.IsNullOrEmpty(r.DevicePublicKeys) + ? [] + : r.DevicePublicKeys.ParseMda(Convert.FromBase64String) + }; + + private static string CredId(byte[] credId) => Convert.ToBase64String(credId); + + public void CreatePasskey(SavedPasskey key) { + db.UserPasskeys.Add(new DbUserPasskey { + OwnerId = key.OwnerId, + Name = key.Name, + CredentialId = Convert.ToBase64String(key.CredentialId!), + PublicKey = Convert.ToBase64String(key.PublicKey!), + SignCount = (int)key.SignCount, + AaGuid = key.AaGuid!.Value.ToString(), + AttesClientDataJson = Convert.ToBase64String(key.AttestationClientDataJson!), + DescriptorType = key.Descriptor!.Type.GetIndex(), + DescriptorId = Convert.ToBase64String(key.Descriptor.Id), + DescriptorTransports = key.Descriptor.Transports == null ? 0 : key.Descriptor.Transports.ToBitmask(), + AttesFormat = key.AttestationFormat, + Transports = key.Transports == null ? 0 : key.Transports.ToBitmask(), + BackupEligible = key.IsBackupEligible, + BackedUp = key.IsBackedUp, + AttesObject = Convert.ToBase64String(key.AttestationObject!), + DevicePublicKeys = key.DevicePublicKeys == null || key.DevicePublicKeys.Length == 0 + ? "" : key.DevicePublicKeys.StringifyMda() + }); + db.SaveChanges(); + } + + public SavedPasskey[] GetUsersPasskeys(string userId) => + db.UserPasskeys + .Where(p => p.OwnerId == userId) + .AsEnumerable() + .Select(Map) + .ToArray(); + + public SavedPasskey? GetPasskey(byte[] credId) { + string id = CredId(credId); + DbUserPasskey? row = db.UserPasskeys.FirstOrDefault(p => p.CredentialId == id); + return row == null ? null : Map(row); + } + + public string? GetUserIdFromPasskeyId(byte[] credId) { + string id = CredId(credId); + return db.UserPasskeys + .Where(p => p.CredentialId == id) + .Select(p => p.OwnerId) + .FirstOrDefault(); + } + + public void SetPasskeySignCount(byte[] credId, int val) { + string id = CredId(credId); + DbUserPasskey? row = db.UserPasskeys.FirstOrDefault(p => p.CredentialId == id); + if (row == null) return; + row.SignCount = val; + db.SaveChanges(); + } + + public void DeletePasskey(byte[] credId) { + string id = CredId(credId); + DbUserPasskey? row = db.UserPasskeys.FirstOrDefault(p => p.CredentialId == id); + if (row == null) return; + db.UserPasskeys.Remove(row); + db.SaveChanges(); + } + + public void UpdatePasskeyDevicePublicKeys(byte[] credId, byte[][] devicePublicKeys) { + string id = CredId(credId); + DbUserPasskey? row = db.UserPasskeys.FirstOrDefault(p => p.CredentialId == id); + if (row == null) return; + row.DevicePublicKeys = devicePublicKeys.StringifyMda(); + db.SaveChanges(); + } +} diff --git a/Repositories/Impl/ProductRepository.cs b/Repositories/Impl/ProductRepository.cs new file mode 100644 index 0000000..6682983 --- /dev/null +++ b/Repositories/Impl/ProductRepository.cs @@ -0,0 +1,27 @@ +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class ProductRepository(SerbleDbContext db) : IProductRepository { + + public string[] GetOwnedProducts(string userId) => + db.OwnedProducts + .Where(p => p.User == userId) + .Select(p => p.Product!) + .ToArray(); + + public void AddOwnedProducts(string userId, string[] productIds) { + foreach (string productId in productIds) { + db.OwnedProducts.Add(new DbOwnedProduct { User = userId, Product = productId }); + } + db.SaveChanges(); + } + + public void RemoveOwnedProduct(string userId, string productId) { + DbOwnedProduct? row = db.OwnedProducts + .FirstOrDefault(p => p.User == userId && p.Product == productId); + if (row == null) return; + db.OwnedProducts.Remove(row); + db.SaveChanges(); + } +} diff --git a/Repositories/Impl/UserRepository.cs b/Repositories/Impl/UserRepository.cs new file mode 100644 index 0000000..47348c7 --- /dev/null +++ b/Repositories/Impl/UserRepository.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using SerbleAPI.Data.Schemas; +using SerbleAPI.Models; + +namespace SerbleAPI.Repositories.Impl; + +public class UserRepository(SerbleDbContext db) : IUserRepository { + + // ── Mapping helpers ─────────────────────────────────────────────────────── + + private static User Map(DbUser r) => new() { + Id = r.Id, + Username = r.Username ?? "", + Email = r.Email ?? "", + VerifiedEmail = r.VerifiedEmail, + PasswordHash = r.Password ?? "", + PermLevel = r.PermLevel, + StripeCustomerId = r.SubscriptionId, + Language = r.Language, + TotpEnabled = r.TotpEnabled, + TotpSecret = r.TotpSecret, + PasswordSalt = r.PasswordSalt + }; + + // ── Users ───────────────────────────────────────────────────────────────── + + public User? GetUser(string userId) { + DbUser? row = db.Users.Find(userId); + return row == null ? null : Map(row); + } + + public User? GetUserFromName(string userName) { + DbUser? row = db.Users.FirstOrDefault(u => u.Username == userName); + return row == null ? null : Map(row); + } + + public User? GetUserFromStripeCustomerId(string customerId) { + DbUser? row = db.Users.FirstOrDefault(u => u.SubscriptionId == customerId); + return row == null ? null : Map(row); + } + + public void AddUser(User user, out User newUser) { + user.Id = Guid.NewGuid().ToString(); + db.Users.Add(new DbUser { + Id = user.Id, + Username = user.Username, + Email = user.Email, + VerifiedEmail = user.VerifiedEmail, + Password = user.PasswordHash, + PermLevel = user.PermLevel, + SubscriptionId = user.StripeCustomerId, + Language = user.Language, + TotpEnabled = user.TotpEnabled, + TotpSecret = user.TotpSecret, + PasswordSalt = user.PasswordSalt + }); + db.SaveChanges(); + newUser = user; + } + + public void UpdateUser(User user) { + DbUser? row = db.Users.Find(user.Id); + if (row == null) return; + row.Username = user.Username; + row.Email = user.Email; + row.VerifiedEmail = user.VerifiedEmail; + row.Password = user.PasswordHash; + row.PermLevel = user.PermLevel; + row.SubscriptionId = user.StripeCustomerId; + row.Language = user.Language; + row.TotpEnabled = user.TotpEnabled; + row.TotpSecret = user.TotpSecret; + row.PasswordSalt = user.PasswordSalt; + db.SaveChanges(); + } + + public void DeleteUser(string userId) { + DbUser? row = db.Users.Find(userId); + if (row == null) return; + // Cascade: remove authorized apps too + db.UserAuthorizedApps.Where(a => a.UserId == userId).ExecuteDelete(); + db.Users.Remove(row); + db.SaveChanges(); + } + + public long CountUsers() => db.Users.LongCount(); + + // ── Authorized apps ─────────────────────────────────────────────────────── + + public void AddAuthorizedApp(string userId, AuthorizedApp app) { + // Remove existing entry for same app so we can replace it cleanly + db.UserAuthorizedApps + .Where(a => a.UserId == userId && a.AppId == app.AppId) + .ExecuteDelete(); + db.UserAuthorizedApps.Add(new DbUserAuthorizedApp { + UserId = userId, + AppId = app.AppId, + Scopes = app.Scopes + }); + db.SaveChanges(); + } + + public AuthorizedApp[] GetAuthorizedApps(string userId) => + db.UserAuthorizedApps + .Where(a => a.UserId == userId) + .Select(a => new AuthorizedApp(a.AppId!, a.Scopes!)) + .ToArray(); + + public void DeleteAuthorizedApp(string userId, string appId) { + db.UserAuthorizedApps + .Where(a => a.UserId == userId && a.AppId == appId) + .ExecuteDelete(); + } +} diff --git a/SerbleAPI.csproj b/SerbleAPI.csproj index b451593..90d0628 100644 --- a/SerbleAPI.csproj +++ b/SerbleAPI.csproj @@ -8,14 +8,18 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - + diff --git a/Services/IAntiSpamService.cs b/Services/IAntiSpamService.cs new file mode 100644 index 0000000..9be0641 --- /dev/null +++ b/Services/IAntiSpamService.cs @@ -0,0 +1,9 @@ +using SerbleAPI.Data.ApiDataSchemas; +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services; + +public interface IAntiSpamService { + public Task Check(AntiSpamHeader header, HttpContext context, + SerbleAuthorizationHeaderType authType = SerbleAuthorizationHeaderType.Null, User? user = null); +} diff --git a/Services/IEmailConfirmationService.cs b/Services/IEmailConfirmationService.cs new file mode 100644 index 0000000..64d232e --- /dev/null +++ b/Services/IEmailConfirmationService.cs @@ -0,0 +1,7 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services; + +public interface IEmailConfirmationService { + void SendConfirmationEmail(User user); +} diff --git a/Services/IGoogleReCaptchaService.cs b/Services/IGoogleReCaptchaService.cs new file mode 100644 index 0000000..b627ee4 --- /dev/null +++ b/Services/IGoogleReCaptchaService.cs @@ -0,0 +1,7 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services; + +public interface IGoogleReCaptchaService { + Task VerifyReCaptcha(string token); +} diff --git a/Services/ITokenService.cs b/Services/ITokenService.cs new file mode 100644 index 0000000..8924ac7 --- /dev/null +++ b/Services/ITokenService.cs @@ -0,0 +1,26 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services; + +public interface ITokenService { + string GenerateLoginToken(string userid); + bool ValidateLoginToken(string token, out User? user); + + string GenerateAuthorizationToken(string userId, string appId, string scopeString); + bool ValidateAuthorizationToken(string token, string appId, out User? user, out string scopeString, + out string reason); + + string GenerateAccessToken(string userId, string scope); + bool ValidateAccessToken(string token, out User? user, out string scope); + + string GenerateRefreshToken(string userId, string appId, string scope); + bool ValidateRefreshToken(string token, string appId, out User? user, out string scope); + + string GenerateEmailConfirmationToken(string userId, string email); + bool ValidateEmailConfirmationToken(string token, out User user, out string email); + + string GenerateFirstStepLoginToken(string userId); + bool ValidateFirstStepLoginToken(string token, out User user); + + string GenerateCheckoutSuccessToken(string productId, string secret); +} diff --git a/Services/ITurnstileCaptchaService.cs b/Services/ITurnstileCaptchaService.cs new file mode 100644 index 0000000..7ed6a3e --- /dev/null +++ b/Services/ITurnstileCaptchaService.cs @@ -0,0 +1,7 @@ +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services; + +public interface ITurnstileCaptchaService { + Task VerifyCaptcha(string token); +} diff --git a/Data/ApiDataSchemas/AntiSpamProtection.cs b/Services/Impl/AntiSpamService.cs similarity index 52% rename from Data/ApiDataSchemas/AntiSpamProtection.cs rename to Services/Impl/AntiSpamService.cs index 14ff6ba..023e81a 100644 --- a/Data/ApiDataSchemas/AntiSpamProtection.cs +++ b/Services/Impl/AntiSpamService.cs @@ -1,25 +1,21 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SerbleAPI.Config; +using SerbleAPI.Data.ApiDataSchemas; using SerbleAPI.Data.Schemas; -namespace SerbleAPI.Data.ApiDataSchemas; +namespace SerbleAPI.Services.Impl; -public class AntiSpamProtection { +public class AntiSpamService(IOptions apiSettings, IGoogleReCaptchaService recaptcha, ITurnstileCaptchaService turnstile) : IAntiSpamService { - [FromHeader] - // Either: - // ReCaptcha | recaptcha TOKEN - // Testing Bypass | bypass testing - // Or be logged in with a verified email - public string SerbleAntiSpam { get; set; } = null!; - - public async Task Check(HttpContext context, SerbleAuthorizationHeaderType authType = SerbleAuthorizationHeaderType.Null, User? user = null) { + public async Task Check(AntiSpamHeader header, HttpContext context, + SerbleAuthorizationHeaderType authType = SerbleAuthorizationHeaderType.Null, User? user = null) { // If the user is logged in with a verified email address, then they are automatically verified if (user != null && authType == SerbleAuthorizationHeaderType.User && user.VerifiedEmail) { return true; } - string[] split = SerbleAntiSpam.Split(' '); + string[] split = header.SerbleAntiSpam.Split(' '); if (split.Length != 2) { return false; } @@ -30,7 +26,7 @@ public async Task Check(HttpContext context, SerbleAuthorizationHeaderType return false; case "recaptcha": { - GoogleReCaptchaResponse response = await GoogleReCaptchaHandler.VerifyReCaptcha(split[1]); + GoogleReCaptchaResponse response = await recaptcha.VerifyReCaptcha(split[1]); if (!response.Success) { return false; } @@ -39,15 +35,15 @@ public async Task Check(HttpContext context, SerbleAuthorizationHeaderType } case "turnstile": { - GoogleReCaptchaResponse response = await TurnstileCaptchaHandler.VerifyCaptcha(split[1]); + GoogleReCaptchaResponse response = await turnstile.VerifyCaptcha(split[1]); return response.Success; } case "bypass": return split[1] switch { - "testing" => Program.Testing, + "testing" => apiSettings.Value.AllowAntiSpamBypass, _ => false }; } } -} \ No newline at end of file +} diff --git a/Services/Impl/EmailConfirmationService.cs b/Services/Impl/EmailConfirmationService.cs new file mode 100644 index 0000000..1f3229d --- /dev/null +++ b/Services/Impl/EmailConfirmationService.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Options; +using SerbleAPI.Config; +using SerbleAPI.Data; +using SerbleAPI.Data.Schemas; + +namespace SerbleAPI.Services.Impl; + +public class EmailConfirmationService(ILogger logger, IOptions settings, + IOptions apiSettings, ITokenService tokens) : IEmailConfirmationService { + + public void SendConfirmationEmail(User user) { + if (user.VerifiedEmail) { + throw new Exception("User has already verified their email"); + } + + string body = EmailSchemasService.GetEmailSchema(EmailSchema.ConfirmationEmail, LocalisationHandler.LanguageOrDefault(user)); + body = body.Replace("{name}", user.Username); + body = body.Replace( + "{confirmation_link}", + apiSettings.Value.LiveUrl + "api/v1/emailconfirm?token=" + tokens.GenerateEmailConfirmationToken(user.Id, user.Email)); + + Email confirmationEmail = new (logger, settings.Value, user.Email.ToSingleItemEnumerable().ToArray()) { + Subject = "Serble Email Confirmation", + Body = body + }; + + confirmationEmail.SendAsync().ContinueWith(_ => logger.LogDebug("Sent confirmation email to " + user.Email)); + logger.LogDebug("Sending confirmation email to " + user.Email); + } + +} \ No newline at end of file diff --git a/Data/GoogleReCaptchaHandler.cs b/Services/Impl/GoogleReCaptchaService.cs similarity index 60% rename from Data/GoogleReCaptchaHandler.cs rename to Services/Impl/GoogleReCaptchaService.cs index 4cd0698..c49d9f2 100644 --- a/Data/GoogleReCaptchaHandler.cs +++ b/Services/Impl/GoogleReCaptchaService.cs @@ -1,30 +1,28 @@ -using GeneralPurposeLib; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; using Newtonsoft.Json; +using SerbleAPI.Config; using SerbleAPI.Data.Schemas; -namespace SerbleAPI.Data; +namespace SerbleAPI.Services.Impl; -public static class GoogleReCaptchaHandler { +public class GoogleReCaptchaService(IOptions settings) : IGoogleReCaptchaService { - public static async Task VerifyReCaptcha(string token) { + public async Task VerifyReCaptcha(string token) { HttpClient client = new(); string url = "https://www.google.com/recaptcha/api/siteverify"; - url = QueryHelpers.AddQueryString(url, "secret", Program.Config!["google_recaptcha_secret_key"]); + url = QueryHelpers.AddQueryString(url, "secret", settings.Value.SecretKey); url = QueryHelpers.AddQueryString(url, "response", token); HttpResponseMessage response; try { response = await client.PostAsync(url, null); } - catch (Exception e) { - Logger.Error("Error validating recaptcha: " + e); + catch (Exception) { return new GoogleReCaptchaResponse(false); } string json = await response.Content.ReadAsStringAsync(); GoogleReCaptchaResponse result = JsonConvert.DeserializeObject(json)!; - Logger.Debug($"ReCaptcha Score: {result.Score}"); return result; } - } \ No newline at end of file diff --git a/Data/TokenHandler.cs b/Services/Impl/TokenService.cs similarity index 63% rename from Data/TokenHandler.cs rename to Services/Impl/TokenService.cs index cc0d208..68ad87f 100644 --- a/Data/TokenHandler.cs +++ b/Services/Impl/TokenService.cs @@ -1,19 +1,21 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using GeneralPurposeLib; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using SerbleAPI.Config; +using SerbleAPI.Data; using SerbleAPI.Data.Schemas; +using SerbleAPI.Repositories; -namespace SerbleAPI.Data; +namespace SerbleAPI.Services.Impl; -public static class TokenHandler { +public class TokenService(IOptions settings, ILogger logger, IUserRepository userRepo) : ITokenService { // User Tokens // Claims: // - userid - - public static string GenerateLoginToken(string userid) { + public string GenerateLoginToken(string userid) { Dictionary claims = new() { { "userid", userid }, { "type", "user" } @@ -21,27 +23,23 @@ public static string GenerateLoginToken(string userid) { return GenerateToken(claims); } - public static bool ValidateLoginToken(string token, out User? user) { + public bool ValidateLoginToken(string token, out User? user) { user = null; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); return false; } claims.ThrowIfNull(); - if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type")) { - return false; - } - if (claims["type"] != "user") { - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type")) return false; + if (claims["type"] != "user") return false; + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser; + user = gottenUser!.WithRepos(userRepo); return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -49,8 +47,7 @@ public static bool ValidateLoginToken(string token, out User? user) { // Authorization Tokens // Claims: // - userid - - public static string GenerateAuthorizationToken(string userId, string appId, string scopeString) { + public string GenerateAuthorizationToken(string userId, string appId, string scopeString) { Dictionary claims = new() { { "userid", userId }, { "appid", appId }, @@ -60,13 +57,13 @@ public static string GenerateAuthorizationToken(string userId, string appId, str return GenerateToken(claims); } - public static bool ValidateAuthorizationToken(string token, string appId, out User? user, out string scopeString, out string reason) { + public bool ValidateAuthorizationToken(string token, string appId, out User? user, out string scopeString, out string reason) { user = null; scopeString = ""; reason = "Unknown Error"; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); reason = "Token validation failed: " + validationFailMsg; return false; } @@ -75,24 +72,17 @@ public static bool ValidateAuthorizationToken(string token, string appId, out Us reason = "Missing claims"; return false; } - if (claims["type"] != "oauth-authorization") { - reason = "Invalid token type"; - return false; - } - if (claims["appid"] != appId) { - reason = "Invalid app id"; - Logger.Debug("App ID mismatch: " + claims["appid"] + " (Token) != " + appId + " (Expected)"); - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (claims["type"] != "oauth-authorization") { reason = "Invalid token type"; return false; } + if (claims["appid"] != appId) { reason = "Invalid app id"; return false; } + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser; + user = gottenUser!.WithRepos(userRepo); scopeString = claims["scope"]; reason = "Success"; return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -100,7 +90,7 @@ public static bool ValidateAuthorizationToken(string token, string appId, out Us // Access Tokens // Claims: // - userid - public static string GenerateAccessToken(string userId, string scope) { + public string GenerateAccessToken(string userId, string scope) { Dictionary claims = new() { { "userid", userId }, { "scope", scope}, @@ -109,29 +99,25 @@ public static string GenerateAccessToken(string userId, string scope) { return GenerateToken(claims, 1); } - public static bool ValidateAccessToken(string token, out User? user, out string scope) { + public bool ValidateAccessToken(string token, out User? user, out string scope) { user = null; scope = ""; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); return false; } claims.ThrowIfNull(); - if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("scope")) { - return false; - } - if (claims["type"] != "oauth-access") { - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("scope")) return false; + if (claims["type"] != "oauth-access") return false; + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser; + user = gottenUser!.WithRepos(userRepo); scope = claims["scope"]; return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -141,7 +127,7 @@ public static bool ValidateAccessToken(string token, out User? user, out string // - userid // - appid // - scope - public static string GenerateRefreshToken(string userId, string appId, string scope) { + public string GenerateRefreshToken(string userId, string appId, string scope) { Dictionary claims = new() { { "userid", userId }, { "appid", appId }, @@ -151,32 +137,26 @@ public static string GenerateRefreshToken(string userId, string appId, string sc return GenerateToken(claims); } - public static bool ValidateRefreshToken(string token, string appId, out User? user, out string scope) { + public bool ValidateRefreshToken(string token, string appId, out User? user, out string scope) { user = null; scope = ""; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); return false; } claims.ThrowIfNull(); - if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("appid") || !claims.ContainsKey("scope")) { - return false; - } - if (claims["type"] != "oauth-refresh") { - return false; - } - if (claims["appid"] != appId) { - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("appid") || !claims.ContainsKey("scope")) return false; + if (claims["type"] != "oauth-refresh") return false; + if (claims["appid"] != appId) return false; + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser; + user = gottenUser!.WithRepos(userRepo); scope = claims["scope"]; return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -185,7 +165,7 @@ public static bool ValidateRefreshToken(string token, string appId, out User? us // Claims: // - userid // - email - public static string GenerateEmailConfirmationToken(string userId, string email) { + public string GenerateEmailConfirmationToken(string userId, string email) { Dictionary claims = new() { { "userid", userId }, { "email", email }, @@ -194,29 +174,25 @@ public static string GenerateEmailConfirmationToken(string userId, string email) return GenerateToken(claims); } - public static bool ValidateEmailConfirmationToken(string token, out User user, out string email) { + public bool ValidateEmailConfirmationToken(string token, out User user, out string email) { user = null!; email = ""; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); return false; } claims.ThrowIfNull(); - if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("email")) { - return false; - } - if (claims["type"] != "email-confirmation") { - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type") || !claims.ContainsKey("email")) return false; + if (claims["type"] != "email-confirmation") return false; + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser!; + user = gottenUser!.WithRepos(userRepo); email = claims["email"]; return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -224,7 +200,7 @@ public static bool ValidateEmailConfirmationToken(string token, out User user, o // First Step Login Token (To confirm user logged in and is awaiting MFA verification) // Claims: // - userid - public static string GenerateFirstStepLoginToken(string userId) { + public string GenerateFirstStepLoginToken(string userId) { Dictionary claims = new() { { "userid", userId }, { "type", "first-step-login" } @@ -232,27 +208,23 @@ public static string GenerateFirstStepLoginToken(string userId) { return GenerateToken(claims); } - public static bool ValidateFirstStepLoginToken(string token, out User user) { + public bool ValidateFirstStepLoginToken(string token, out User user) { user = null!; try { if (!ValidateCurrentToken(token, out Dictionary? claims, out string validationFailMsg)) { - Logger.Debug(validationFailMsg); + logger.LogDebug(validationFailMsg); return false; } claims.ThrowIfNull(); - if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type")) { - return false; - } - if (claims["type"] != "first-step-login") { - return false; - } - Program.StorageService!.GetUser(claims["userid"], out User? gottenUser); + if (!claims!.ContainsKey("userid") || !claims.ContainsKey("type")) return false; + if (claims["type"] != "first-step-login") return false; + User? gottenUser = userRepo.GetUser(claims["userid"]); gottenUser.ThrowIfNull(); - user = gottenUser!; + user = gottenUser!.WithRepos(userRepo); return true; } catch (Exception e) { - Logger.Debug("Token validation failed: " + e); + logger.LogDebug("Token validation failed: " + e); return false; } } @@ -260,7 +232,7 @@ public static bool ValidateFirstStepLoginToken(string token, out User user) { // Checkout Success Token (Given to other sites to confirm a successful checkout) // Claims: // - productid - public static string GenerateCheckoutSuccessToken(string productId, string secret) { + public string GenerateCheckoutSuccessToken(string productId, string secret) { Dictionary claims = new() { { "type", "checkout_success" }, { "productid", productId } @@ -269,25 +241,25 @@ public static string GenerateCheckoutSuccessToken(string productId, string secre } - private static string GenerateToken(Dictionary claims, int expirationInHours = 87600, string? secret = null) { - string mySecret = secret ?? Program.Config!["token_secret"]; + private string GenerateToken(Dictionary claims, int expirationInHours = 87600, string? secret = null) { + string mySecret = secret ?? settings.Value.Secret; SymmetricSecurityKey securityKey = new(Encoding.ASCII.GetBytes(mySecret)); JwtSecurityTokenHandler tokenHandler = new(); SecurityTokenDescriptor tokenDescriptor = new() { Subject = new ClaimsIdentity(claims.Select(c => new Claim(c.Key, c.Value)).ToArray()), Expires = DateTime.Now.AddHours(expirationInHours), - Issuer = Program.Config!["token_issuer"], - Audience = Program.Config!["token_audience"], + Issuer = settings.Value.Issuer, + Audience = settings.Value.Audience, SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature), }; SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } - private static bool ValidateCurrentToken(string? token, out Dictionary? claims, out string failMsg) { + private bool ValidateCurrentToken(string? token, out Dictionary? claims, out string failMsg) { claims = null; failMsg = "Error"; - string mySecret = Program.Config!["token_secret"]; + string mySecret = settings.Value.Secret; SymmetricSecurityKey mySecurityKey = new(Encoding.ASCII.GetBytes(mySecret)); JwtSecurityTokenHandler tokenHandler = new(); try { @@ -295,8 +267,8 @@ private static bool ValidateCurrentToken(string? token, out Dictionary logger, IOptions settings) : ITurnstileCaptchaService { private const string Url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; - public static async Task VerifyCaptcha(string token) { + public async Task VerifyCaptcha(string token) { HttpClient client = new(); dynamic body = new { - secret = Program.Config!["turnstile_captcha_secret_key"], + secret = settings.Value.SecretKey, response = token }; HttpResponseMessage response; @@ -20,13 +20,13 @@ public static async Task VerifyCaptcha(string token) { response = await client.PostAsync(Url, new StringContent(JsonConvert.SerializeObject(body), Encoding.Default, "application/json")); } catch (Exception e) { - Logger.Error("Error validating recaptcha: " + e); + logger.LogError("Error validating recaptcha: " + e); return new GoogleReCaptchaResponse(false); } string json = await response.Content.ReadAsStringAsync(); - Logger.Debug("Turnstile Response: " + json); + logger.LogDebug("Turnstile Response: " + json); GoogleReCaptchaResponse result = JsonConvert.DeserializeObject(json)!; return result; } -} \ No newline at end of file +} diff --git a/appsettings.Development.json b/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/appsettings.json b/appsettings.json index 10f68b8..287a552 100644 --- a/appsettings.json +++ b/appsettings.json @@ -5,5 +5,50 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "MySql": "Server=localhost;Port=3307;Database=serbleapi;User=root;Password=admin;" + }, + "Api": { + "BindUrl": "http://*:5000", + "WebsiteUrl": "https://localhost:7244", + "LiveUrl": "http://localhost:5000/", + "Redirects": { + "discord": "https://discord.gg/fzvcNhW" + } + }, + "Stripe": { + "ApiKey": "somestripeapikey", + "WebhookSecret": "somestripesecret", + "PremiumSubscriptionId": "somepriceid" + }, + "Passkey": { + "RelyingPartyId": "localhost", + "RelyingPartyName": "Serble", + "AllowedOrigins": [ + "https://serble.net", + "https://www.serble.net", + "https://serble", + "http://localhost:5173" + ] + }, + "Email": { + "SmtpHost": "smtp.example.com", + "SmtpPort": 587, + "SmtpUsername": "system@serble.net", + "SmtpPassword": "somepassword", + "Addresses": { + "System": "system@serble.net", + "Newsletter": "newsletter@serble.net", + "Contact": "admin@serble.net" + } + }, + "Jwt": { + "Issuer": "CoPokBl", + "Audience": "Privileged Users", + "Secret": "somejwtsecretkey" + }, + "Turnstile": { + "SecretKey": "somecaptchasecretkey" + } } diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..df5ff23 --- /dev/null +++ b/migrate.py @@ -0,0 +1,156 @@ +import mysql.connector +from mysql.connector import Error + +# ----- CONFIG ----- +MYSQL_HOST = 'mysql.example.com' +MYSQL_USER = 'admin' +MYSQL_PASSWORD = 'PASSWORD' +DATABASE_A = 'serble-old' # Replace with your old database name +DATABASE_B = 'serble-new' # Replace with your new database name + +def migrate(): + try: + cnx = mysql.connector.connect( + host=MYSQL_HOST, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + ) + # Connection will automatically disallow modifications if you use correct queries. + + cursor_a = cnx.cursor(dictionary=True) + cursor_b = cnx.cursor() + + # --- KV STORE --- + cursor_a.execute(f"SELECT k AS `Key`, v AS Value FROM {DATABASE_A}.serblesite_kv") + kvs = cursor_a.fetchall() + for row in kvs: + cursor_b.execute( + f"INSERT INTO {DATABASE_B}.Kvs (`Key`, Value) VALUES (%s, %s)", + (row['Key'], row['Value']) + ) + print("Kvs migrated.") + + # --- Users --- + cursor_a.execute(f""" + SELECT + id AS Id, + username AS Username, + email AS Email, + password AS Password, + permlevel AS PermLevel, + verifiedEmail AS VerifiedEmail, + premiumLevel AS PremiumLevel, + subscriptionId AS SubscriptionId, + language AS Language, + totp_enabled AS TotpEnabled, + totp_secret AS TotpSecret, + password_salt AS PasswordSalt + FROM {DATABASE_A}.serblesite_users + """) + users = cursor_a.fetchall() + for user in users: + # Patch for nulls/defaults + for field in ('Username','PermLevel','VerifiedEmail','PremiumLevel','TotpEnabled'): + if user[field] is None: + user[field] = 0 if field in ('PermLevel','VerifiedEmail','PremiumLevel','TotpEnabled') else '' + cursor_b.execute( + f"""INSERT INTO {DATABASE_B}.Users ( + Id, Username, Email, Password, PermLevel, VerifiedEmail, + PremiumLevel, SubscriptionId, Language, TotpEnabled, TotpSecret, PasswordSalt + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + user['Id'], user['Username'], user['Email'], user['Password'], user['PermLevel'], + user['VerifiedEmail'], user['PremiumLevel'], user['SubscriptionId'], user['Language'], + user['TotpEnabled'], user['TotpSecret'], user['PasswordSalt'] + ) + ) + print("Users migrated.") + + # --- Apps --- + cursor_a.execute(f""" + SELECT + id AS Id, + ownerid AS OwnerId, + name AS Name, + description AS Description, + clientsecret AS ClientSecret, + redirecturi AS RedirectUri + FROM {DATABASE_A}.serblesite_apps + """) + apps = cursor_a.fetchall() + for app in apps: + for col in ('Id','OwnerId','Name','Description','ClientSecret','RedirectUri'): + if app[col] is None: + app[col] = '' + cursor_b.execute( + f"""INSERT INTO {DATABASE_B}.Apps + (Id, OwnerId, Name, Description, ClientSecret, RedirectUri) + VALUES (%s,%s,%s,%s,%s,%s)""", + (app['Id'], app['OwnerId'], app['Name'], app['Description'], app['ClientSecret'], app['RedirectUri']) + ) + print("Apps migrated.") + + # --- Owned Products --- + cursor_a.execute(f"SELECT user as User, product as Product FROM {DATABASE_A}.serblesite_owned_products") + owned = cursor_a.fetchall() + for row in owned: + if row['User'] is None or row['Product'] is None: + continue + cursor_b.execute( + f"INSERT INTO {DATABASE_B}.OwnedProducts (User, Product) VALUES (%s, %s)", + (row['User'], row['Product']) + ) + print("OwnedProducts migrated.") + + # --- User Authorized Apps --- + cursor_a.execute(f""" + SELECT userid AS UserId, appid AS AppId, scopes AS Scopes + FROM {DATABASE_A}.serblesite_user_authorized_apps + """) + authorized = cursor_a.fetchall() + # Build set of valid App Ids and User Ids + cursor_b.execute(f"SELECT Id FROM {DATABASE_B}.Apps") + valid_app_ids = {row[0] for row in cursor_b.fetchall()} + cursor_b.execute(f"SELECT Id FROM {DATABASE_B}.Users") + valid_user_ids = {row[0] for row in cursor_b.fetchall()} + + skipped = 0 + for row in authorized: + if not row['UserId'] or not row['AppId'] or not row['Scopes']: + continue + if row['AppId'] not in valid_app_ids or row['UserId'] not in valid_user_ids: + skipped += 1 + continue # skip orphaned references + cursor_b.execute( + f"INSERT INTO {DATABASE_B}.UserAuthorizedApps (UserId, AppId, Scopes) VALUES (%s,%s,%s)", + (row['UserId'], row['AppId'], row['Scopes']) + ) + print(f"UserAuthorizedApps migrated. Skipped {skipped} invalid rows.") + + # --- User Notes --- + cursor_a.execute(f""" + SELECT noteid AS NoteId, user AS User, note AS Note + FROM {DATABASE_A}.serblesite_user_notes + """) + notes = cursor_a.fetchall() + for note in notes: + if note['User'] is None or note['NoteId'] is None or note['Note'] is None: + continue + cursor_b.execute( + f"INSERT INTO {DATABASE_B}.UserNotes (NoteId, User, Note) VALUES (%s,%s,%s)", + (note['NoteId'], note['User'], note['Note']) + ) + print("UserNotes migrated.") + + cnx.commit() + print("Migration completed successfully.") + + except Error as e: + print("Error: ", e) + finally: + if 'cursor_a' in locals(): cursor_a.close() + if 'cursor_b' in locals(): cursor_b.close() + if 'cnx' in locals(): cnx.close() + +if __name__ == "__main__": + migrate() \ No newline at end of file From 61a6069668bd4ca7ac48227dae0f557d0c07f557 Mon Sep 17 00:00:00 2001 From: CoPokBl Date: Fri, 20 Feb 2026 23:48:29 +1100 Subject: [PATCH 7/7] fix emailconfirm endpoint mixing up redirects --- API/v1/Account/EmailConfirmationController.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/API/v1/Account/EmailConfirmationController.cs b/API/v1/Account/EmailConfirmationController.cs index fe22118..68201e6 100644 --- a/API/v1/Account/EmailConfirmationController.cs +++ b/API/v1/Account/EmailConfirmationController.cs @@ -13,13 +13,12 @@ public class EmailConfirmationController(IOptions apiSettings, ITok [HttpGet] public ActionResult Confirm([FromQuery] string token, [FromQuery] string? redirect = null, [FromQuery] string? failureRedirect = null) { if (!tokens.ValidateEmailConfirmationToken(token, out User user, out string email) || user.Email != email || user.VerifiedEmail) { - return Redirect(redirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/error"); + return Redirect(failureRedirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/error"); } user.VerifiedEmail = true; user.RegisterChanges(); - return Redirect(failureRedirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/success"); + return Redirect(redirect ?? $"{apiSettings.Value.WebsiteUrl}/emailconfirm/success"); } - -} \ No newline at end of file +}