diff --git a/CollabVMAuthServer/AuthServerContext.cs b/CollabVMAuthServer/AuthServerContext.cs new file mode 100644 index 0000000..598afb1 --- /dev/null +++ b/CollabVMAuthServer/AuthServerContext.cs @@ -0,0 +1,26 @@ +using System.CommandLine; +using System.CommandLine.Binding; +namespace Computernewb.CollabVMAuthServer; + +public class AuthServerContext { + public required IConfig Config { get; set; } +} + +public class AuthServerCliOptionsBinder : BinderBase { + private readonly Option _configPathOption; + + public AuthServerCliOptionsBinder(Option configPathOption) { + this._configPathOption = configPathOption; + } + + protected override AuthServerContext GetBoundValue(BindingContext bindingContext) + { + var configPath = bindingContext.ParseResult.GetValueForOption(_configPathOption)!; + // Load config file + var config = IConfig.Load(configPath); + + return new AuthServerContext { + Config = config, + }; + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/Bot.cs b/CollabVMAuthServer/Bot.cs deleted file mode 100644 index 2da95af..0000000 --- a/CollabVMAuthServer/Bot.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace Computernewb.CollabVMAuthServer; - -public class Bot -{ - public uint Id { get; set; } - public string Username { get; set; } - public string Token { get; set; } - public Rank Rank { get; set; } - public string Owner { get; set; } - public DateTime Created { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/CollabVMAuthServer.csproj b/CollabVMAuthServer/CollabVMAuthServer.csproj index ca44ae5..c98bdee 100644 --- a/CollabVMAuthServer/CollabVMAuthServer.csproj +++ b/CollabVMAuthServer/CollabVMAuthServer.csproj @@ -16,8 +16,16 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/CollabVMAuthServer/Cron.cs b/CollabVMAuthServer/Cron.cs index 3ff2497..aeb4d96 100644 --- a/CollabVMAuthServer/Cron.cs +++ b/CollabVMAuthServer/Cron.cs @@ -1,15 +1,26 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; namespace Computernewb.CollabVMAuthServer; -public static class Cron +public class Cron { - private static Timer timer = new Timer(); + private readonly DbContextOptions _dbContextOptions; + private readonly ILogger _logger; + public Cron(DbContextOptions dbContextOptions) { + this._dbContextOptions = dbContextOptions; + this._logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger(); + } + + private Timer timer = new Timer(); - public static async Task Start() + public async Task Start() { #if DEBUG timer.Interval = 1000 * 60; // 60 seconds @@ -21,41 +32,37 @@ public static class Cron timer.Start(); } - public static void Stop() + public void Stop() { timer.Stop(); timer.Interval = 1000 * 60 * 10; } - public static async Task RunAll() + public async Task RunAll() { - Utilities.Log(LogLevel.INFO, "Running all cron jobs"); + _logger.LogDebug("Running all cron jobs"); var t = new List(); t.Add(PurgeOldSessions()); - if (Program.Config.Registration.EmailVerificationRequired) t.Add(ExpireAccounts()); + if (Program.Config.Registration!.EmailVerificationRequired) t.Add(ExpireAccounts()); await Task.WhenAll(t); - Utilities.Log(LogLevel.INFO, "Finished running all cron jobs"); + _logger.LogDebug("Finished running all cron jobs"); } // Expire unverified accounts after 2 days. Don't purge if the code is null - public static async Task ExpireAccounts() + public async Task ExpireAccounts() { - Utilities.Log(LogLevel.INFO, "Purging unverified accounts"); - var minDate = DateTime.UtcNow - TimeSpan.FromDays(2); - int a = await Program.Database.ExecuteNonQuery("DELETE FROM users WHERE email_verified = 0 AND created < @minDate AND email_verification_code IS NOT NULL", - [ - new KeyValuePair("minDate", minDate) - ]); - Utilities.Log(LogLevel.INFO, $"Purged {a} unverified accounts"); + _logger.LogDebug("Purging unverified accounts"); + using var dbContext = new CollabVMAuthDbContext(_dbContextOptions); + dbContext.Users.RemoveRange(dbContext.Users.Where(u => (!u.EmailVerified) && u.EmailVerificationCode != null && (u.Created < DateTime.UtcNow.AddDays(-2)))); + var a = await dbContext.SaveChangesAsync(); + _logger.LogInformation("Purged {a} unverified accounts", a); } - public static async Task PurgeOldSessions() + public async Task PurgeOldSessions() { - Utilities.Log(LogLevel.INFO, "Purging old sessions"); - var expiryDate = DateTime.UtcNow - TimeSpan.FromDays(Program.Config.Accounts.SessionExpiryDays); - int a = await Program.Database.ExecuteNonQuery("DELETE FROM sessions WHERE last_used < @expiryDate", - [ - new KeyValuePair("expiryDate", expiryDate) - ]); - Utilities.Log(LogLevel.INFO, $"Purged {a} old sessions"); + _logger.LogDebug("Purging old sessions"); + using var dbContext = new CollabVMAuthDbContext(_dbContextOptions); + dbContext.Sessions.RemoveRange(dbContext.Sessions.Where(s => s.LastUsed < DateTime.UtcNow - TimeSpan.FromDays(Program.Config.Accounts!.SessionExpiryDays))); + var a = await dbContext.SaveChangesAsync(); + _logger.LogInformation("Purged {a} old sessions", a); } } \ No newline at end of file diff --git a/CollabVMAuthServer/Database.cs b/CollabVMAuthServer/Database.cs deleted file mode 100644 index 7a1b693..0000000 --- a/CollabVMAuthServer/Database.cs +++ /dev/null @@ -1,562 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Net; -using System.Threading.Tasks; -using Isopoh.Cryptography.Argon2; -using MySqlConnector; - -namespace Computernewb.CollabVMAuthServer; - -public class Database -{ - private readonly string connectionString; - - public Database(MySQLConfig config) - { - connectionString = new MySqlConnectionStringBuilder - { - Server = config.Host, - UserID = config.Username, - Password = config.Password, - Database = config.Database - }.ToString(); - } - - public async Task Init() - { - await using var conn = new MySqlConnection(connectionString); - await conn.OpenAsync(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS users ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(20) NOT NULL UNIQUE KEY, - password TEXT NOT NULL, - email TEXT NOT NULL UNIQUE KEY, - date_of_birth DATE NOT NULL, - email_verified BOOLEAN NOT NULL DEFAULT 0, - email_verification_code CHAR(8) DEFAULT NULL, - password_reset_code CHAR(8) DEFAULT NULL, - cvm_rank INT UNSIGNED NOT NULL DEFAULT 1, - banned BOOLEAN NOT NULL DEFAULT 0, - ban_reason TEXT DEFAULT NULL, - registration_ip VARBINARY(16) NOT NULL, - created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - developer BOOLEAN NOT NULL DEFAULT 0 - ); - """; - await cmd.ExecuteNonQueryAsync(); - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS sessions ( - token CHAR(32) NOT NULL PRIMARY KEY, - username VARCHAR(20) NOT NULL, - created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_used TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_ip VARBINARY(16) NOT NULL, - FOREIGN KEY (username) REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE - ) - """; - await cmd.ExecuteNonQueryAsync(); - // banned_by being NULL means the ban was automatic - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS ip_bans ( - ip VARBINARY(16) NOT NULL PRIMARY KEY, - reason TEXT NOT NULL, - banned_by VARCHAR(20) DEFAULT NULL, - banned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - """; - await cmd.ExecuteNonQueryAsync(); - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS bots ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(20) NOT NULL UNIQUE KEY, - token CHAR(64) NOT NULL UNIQUE KEY, - cvm_rank INT UNSIGNED NOT NULL DEFAULT 1, - owner VARCHAR(20) NOT NULL, - created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (owner) REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE - ) - """; - await cmd.ExecuteNonQueryAsync(); - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS meta ( - setting VARCHAR(20) NOT NULL PRIMARY KEY, - val TEXT NOT NULL - ) - """; - await cmd.ExecuteNonQueryAsync(); - } - - public async Task GetUser(string? username = null, string? email = null) - { - if (username == null && email == null) - throw new ArgumentException("username or email must be provided"); - await using var conn = new MySqlConnection(connectionString); - await conn.OpenAsync(); - await using var cmd = conn.CreateCommand(); - if (username != null) - { - cmd.CommandText = "SELECT * FROM users WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - } - else if (email != null) - { - cmd.CommandText = "SELECT * FROM users WHERE email = @email"; - cmd.Parameters.AddWithValue("@email", email); - } - await using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) - return null; - return new User - { - Id = reader.GetUInt32("id"), - Username = reader.GetString("username"), - Password = reader.GetString("password"), - Email = reader.GetString("email"), - DateOfBirth = reader.GetDateOnly("date_of_birth"), - EmailVerified = reader.GetBoolean("email_verified"), - EmailVerificationCode = reader.IsDBNull("email_verification_code") ? null : reader.GetString("email_verification_code"), - PasswordResetCode = reader.IsDBNull("password_reset_code") ? null : reader.GetString("password_reset_code"), - Rank = (Rank)reader.GetUInt32("cvm_rank"), - Banned = reader.GetBoolean("banned"), - BanReason = reader.IsDBNull("ban_reason") ? null : reader.GetString("ban_reason"), - RegistrationIP = new IPAddress(reader.GetFieldValue("registration_ip")), - Joined = reader.GetDateTime("created"), - Developer = reader.GetBoolean("developer") - }; - } - - public async Task RegisterAccount(string username, string email, DateOnly dateOfBirth, string password, bool verified, IPAddress ip, - string? verificationcode = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = """ - INSERT INTO users - (username, password, email, date_of_birth, email_verified, email_verification_code, registration_ip) - VALUES - (@username, @password, @email, @date_of_birth, @email_verified, @email_verification_code, @registration_ip) - """; - cmd.Parameters.AddWithValue("@username", username); - cmd.Parameters.AddWithValue("@password", Argon2.Hash(password)); - cmd.Parameters.AddWithValue("@email", email); - cmd.Parameters.Add("@date_of_birth", MySqlDbType.Date).Value = dateOfBirth; - cmd.Parameters.AddWithValue("@email_verified", verified); - cmd.Parameters.AddWithValue("@email_verification_code", verificationcode); - cmd.Parameters.AddWithValue("@registration_ip", ip.GetAddressBytes()); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task SetUserVerified(string username, bool verified) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "UPDATE users SET email_verified = @verified WHERE username = @username"; - cmd.Parameters.AddWithValue("@verified", verified); - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task SetVerificationCode(string username, string code) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "UPDATE users SET email_verification_code = @code WHERE username = @username"; - cmd.Parameters.AddWithValue("@code", code); - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task CreateSession(string username, string token, IPAddress ip) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "INSERT INTO sessions (token, username, last_ip) VALUES (@token, @username, @ip)"; - cmd.Parameters.AddWithValue("@token", token); - cmd.Parameters.AddWithValue("@username", username); - cmd.Parameters.AddWithValue("@ip", ip.GetAddressBytes()); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task GetSessions(string username) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT * FROM sessions WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - await using var reader = await cmd.ExecuteReaderAsync(); - var sessions = new List(); - while (await reader.ReadAsync()) - { - sessions.Add(new Session - { - Token = reader.GetString("token"), - Username = reader.GetString("username"), - Created = reader.GetDateTime("created"), - LastUsed = reader.GetDateTime("last_used"), - LastIP = new IPAddress(reader.GetFieldValue("last_ip")) - }); - } - return sessions.ToArray(); - } - - public async Task GetSession(string token) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT * FROM sessions WHERE token = @token"; - cmd.Parameters.AddWithValue("@token", token); - await using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) - return null; - return new Session - { - Token = reader.GetString("token"), - Username = reader.GetString("username"), - Created = reader.GetDateTime("created"), - LastUsed = reader.GetDateTime("last_used"), - LastIP = new IPAddress(reader.GetFieldValue("last_ip")) - }; - } - - public async Task RevokeSession(string token) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "DELETE FROM sessions WHERE token = @token"; - cmd.Parameters.AddWithValue("@token", token); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task RevokeAllSessions(string username) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "DELETE FROM sessions WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task UpdateSessionLastUsed(string token, IPAddress ip) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "UPDATE sessions SET last_used = CURRENT_TIMESTAMP, last_ip = @ip WHERE token = @token"; - cmd.Parameters.AddWithValue("@token", token); - cmd.Parameters.AddWithValue("@ip", ip.GetAddressBytes()); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task UpdateUser(string username, string? newUsername = null, string? newPassword = null, string? newEmail = null, int? newRank = null, bool? developer = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - var updates = new List(); - if (newUsername != null) - { - updates.Add("username = @newUsername"); - cmd.Parameters.AddWithValue("@newUsername", newUsername); - } - if (newPassword != null) - { - updates.Add("password = @newPassword"); - cmd.Parameters.AddWithValue("@newPassword", Argon2.Hash(newPassword)); - } - if (newEmail != null) - { - updates.Add("email = @newEmail"); - cmd.Parameters.AddWithValue("@newEmail", newEmail); - } - - if (newRank != null) - { - updates.Add("cvm_rank = @newRank"); - cmd.Parameters.AddWithValue("@newRank", newRank); - } - if (developer != null) - { - updates.Add("developer = @developer"); - cmd.Parameters.AddWithValue("@developer", developer); - } - cmd.CommandText = $"UPDATE users SET {string.Join(", ", updates)} WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task BanIP(IPAddress ip, string reason, string? bannedBy = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "INSERT INTO ip_bans (ip, reason, banned_by) VALUES (@ip, @reason, @bannedBy)"; - cmd.Parameters.AddWithValue("@ip", ip.GetAddressBytes()); - cmd.Parameters.AddWithValue("@reason", reason); - cmd.Parameters.AddWithValue("@bannedBy", bannedBy); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task UnbanIP(IPAddress ip) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "DELETE FROM ip_bans WHERE ip = @ip"; - cmd.Parameters.AddWithValue("@ip", ip.GetAddressBytes()); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task CheckIPBan(IPAddress ip) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT * FROM ip_bans WHERE ip = @ip"; - cmd.Parameters.AddWithValue("@ip", ip.GetAddressBytes()); - await using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) - return null; - return new IPBan - { - IP = new IPAddress(reader.GetFieldValue("ip")), - Reason = reader.GetString("reason"), - BannedBy = reader.IsDBNull("banned_by") ? null : reader.GetString("banned_by"), - BannedAt = reader.GetDateTime("banned_at") - }; - } - - public async Task SetPasswordResetCode(string username, string? code) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "UPDATE users SET password_reset_code = @code WHERE username = @username"; - cmd.Parameters.AddWithValue("@code", code); - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task ListUsers(string? filterUsername = null, IPAddress? filterIp = null, string orderBy = "id", bool descending = false) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - var where = new List(); - if (filterUsername != null) - { - where.Add("username LIKE @filterUsername"); - cmd.Parameters.AddWithValue("@filterUsername", filterUsername); - } - if (filterIp != null) - { - where.Add("registration_ip = @filterIp"); - cmd.Parameters.AddWithValue("@filterIp", filterIp.GetAddressBytes()); - } - cmd.CommandText = $"SELECT * FROM users {(where.Count > 0 ? "WHERE" : "")} {string.Join(" AND ", where)} ORDER BY {orderBy} {(descending ? "DESC" : "ASC")}"; - await using var reader = await cmd.ExecuteReaderAsync(); - var users = new List(); - while (await reader.ReadAsync()) - { - users.Add(new User - { - Id = reader.GetUInt32("id"), - Username = reader.GetString("username"), - Password = reader.GetString("password"), - Email = reader.GetString("email"), - DateOfBirth = reader.GetDateOnly("date_of_birth"), - EmailVerified = reader.GetBoolean("email_verified"), - EmailVerificationCode = reader.IsDBNull("email_verification_code") ? null : reader.GetString("email_verification_code"), - PasswordResetCode = reader.IsDBNull("password_reset_code") ? null : reader.GetString("password_reset_code"), - Rank = (Rank)reader.GetUInt32("cvm_rank"), - Banned = reader.GetBoolean("banned"), - BanReason = reader.IsDBNull("ban_reason") ? null : reader.GetString("ban_reason"), - RegistrationIP = new IPAddress(reader.GetFieldValue("registration_ip")), - Joined = reader.GetDateTime("created"), - Developer = reader.GetBoolean("developer") - }); - } - return users.ToArray(); - } - - public async Task CreateBot(string username, string token, string owner) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "INSERT INTO bots (username, token, owner) VALUES (@username, @token, @owner)"; - cmd.Parameters.AddWithValue("@username", username); - cmd.Parameters.AddWithValue("@token", token); - cmd.Parameters.AddWithValue("@owner", owner); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task ListBots(string? owner = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - var where = new List(); - if (owner != null) - { - where.Add("owner = @owner"); - cmd.Parameters.AddWithValue("@owner", owner); - } - cmd.CommandText = $"SELECT * FROM bots {(where.Count > 0 ? "WHERE" : "")} {string.Join(" AND ", where)}"; - await using var reader = await cmd.ExecuteReaderAsync(); - var bots = new List(); - while (await reader.ReadAsync()) - { - bots.Add(new Bot - { - Id = reader.GetUInt32("id"), - Username = reader.GetString("username"), - Token = reader.GetString("token"), - Rank = (Rank)reader.GetUInt32("cvm_rank"), - Owner = reader.GetString("owner"), - Created = reader.GetDateTime("created") - }); - } - return bots.ToArray(); - } - - public async Task UpdateBot(string username, string? newUsername = null, string? newToken = null, int? newRank = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - var updates = new List(); - if (newUsername != null) - { - updates.Add("username = @username"); - cmd.Parameters.AddWithValue("@username", newUsername); - } - if (newToken != null) - { - updates.Add("token = @token"); - cmd.Parameters.AddWithValue("@token", newToken); - } - if (newRank != null) - { - updates.Add("cvm_rank = @rank"); - cmd.Parameters.AddWithValue("@rank", newRank); - } - cmd.CommandText = $"UPDATE bots SET {string.Join(", ", updates)} WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task DeleteBots(string owner) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "DELETE FROM bots WHERE owner = @owner"; - cmd.Parameters.AddWithValue("@owner", owner); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task GetBot(string? username = null, string? token = null) - { - if (username == null && token == null) - throw new ArgumentException("username or token must be provided"); - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - if (username != null) - { - cmd.CommandText = "SELECT * FROM bots WHERE username = @username"; - cmd.Parameters.AddWithValue("@username", username); - } - else if (token != null) - { - cmd.CommandText = "SELECT * FROM bots WHERE token = @token"; - cmd.Parameters.AddWithValue("@token", token); - } - await using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) - return null; - return new Bot - { - Id = reader.GetUInt32("id"), - Username = reader.GetString("username"), - Token = reader.GetString("token"), - Rank = (Rank)reader.GetUInt32("cvm_rank"), - Owner = reader.GetString("owner"), - Created = reader.GetDateTime("created") - }; - } - - public async Task GetDatabaseVersion() - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - // If `users` table doesn't exist, return -1. This is hacky but I don't know of a better way - cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users'"; - if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0) - return -1; - // If `meta` table doesn't exist, assume version 0 - cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'meta'"; - if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0) - return 0; - cmd.CommandText = "SELECT val FROM meta WHERE setting = 'db_version'"; - await using var reader = await cmd.ExecuteReaderAsync(); - await reader.ReadAsync(); - return int.Parse(reader.GetString("val")); - } - - public async Task SetDatabaseVersion(int version) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "INSERT INTO meta (setting, val) VALUES ('db_version', @version) ON DUPLICATE KEY UPDATE val = @version"; - cmd.Parameters.AddWithValue("@version", version.ToString()); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task ExecuteNonQuery(string query, KeyValuePair[]? parameters = null) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = query; - if (parameters != null) foreach (KeyValuePair param in parameters) - { - cmd.Parameters.AddWithValue(param.Key, param.Value); - } - return await cmd.ExecuteNonQueryAsync(); - } - - public async Task SetBanned(string username, bool banned, string? reason) - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "UPDATE users SET banned = @banned, ban_reason = @reason WHERE username = @username"; - cmd.Parameters.AddWithValue("@banned", banned); - cmd.Parameters.AddWithValue("@reason", reason); - cmd.Parameters.AddWithValue("@username", username); - await cmd.ExecuteNonQueryAsync(); - } - - public async Task CountUsers() - { - await using var db = new MySqlConnection(connectionString); - await db.OpenAsync(); - await using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM users"; - return (long)await cmd.ExecuteScalarAsync(); - } -} \ No newline at end of file diff --git a/CollabVMAuthServer/Database/CollabVMAuthDbContext.cs b/CollabVMAuthServer/Database/CollabVMAuthDbContext.cs new file mode 100644 index 0000000..d82099c --- /dev/null +++ b/CollabVMAuthServer/Database/CollabVMAuthDbContext.cs @@ -0,0 +1,174 @@ +using Computernewb.CollabVMAuthServer.Database.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Computernewb.CollabVMAuthServer.Database; + +public partial class CollabVMAuthDbContext : DbContext +{ + #pragma warning disable CS8618 + public CollabVMAuthDbContext(DbContextOptions options) + : base(options) + { + } + #pragma warning restore CS8618 + + public virtual DbSet Bots { get; set; } + + public virtual DbSet IpBans { get; set; } + public virtual DbSet Sessions { get; set; } + + public virtual DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .UseCollation("utf8mb4_unicode_ci") + .HasCharSet("utf8mb4"); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("PRIMARY"); + + entity.ToTable("bots"); + + entity.HasIndex(e => e.Owner, "owner"); + + entity.HasIndex(e => e.Token, "token").IsUnique(); + + entity.HasIndex(e => e.Username, "username").IsUnique(); + + entity.Property(e => e.Id) + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + entity.Property(e => e.Created) + .HasDefaultValueSql("current_timestamp()") + .HasColumnType("timestamp") + .HasColumnName("created"); + entity.Property(e => e.CvmRank) + .HasDefaultValueSql("'1'") + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank"); + entity.Property(e => e.Owner) + .HasColumnName("owner"); + entity.Property(e => e.Token) + .HasMaxLength(64) + .IsFixedLength() + .HasColumnName("token"); + entity.Property(e => e.Username) + .HasMaxLength(20) + .HasColumnName("username"); + + entity.HasOne(d => d.OwnerNavigation).WithMany(p => p.Bots) + .HasPrincipalKey(p => p.Id) + .HasForeignKey(d => d.Owner) + .HasConstraintName("owner"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Ip).HasName("PRIMARY"); + + entity.ToTable("ip_bans"); + + entity.Property(e => e.Ip) + .HasMaxLength(16) + .HasColumnName("ip"); + entity.Property(e => e.BannedAt) + .HasDefaultValueSql("current_timestamp()") + .HasColumnType("timestamp") + .HasColumnName("banned_at"); + entity.Property(e => e.BannedBy) + .HasMaxLength(20) + .HasColumnName("banned_by"); + entity.Property(e => e.Reason) + .HasColumnType("text") + .HasColumnName("reason"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Token).HasName("PRIMARY"); + + entity.ToTable("sessions"); + + entity.HasIndex(e => e.UserId, "user"); + + entity.Property(e => e.Token) + .HasMaxLength(32) + .IsFixedLength() + .HasColumnName("token"); + entity.Property(e => e.Created) + .HasDefaultValueSql("current_timestamp()") + .HasColumnType("timestamp") + .HasColumnName("created"); + entity.Property(e => e.LastIp) + .HasMaxLength(16) + .HasColumnName("last_ip"); + entity.Property(e => e.LastUsed) + .HasDefaultValueSql("current_timestamp()") + .HasColumnType("timestamp") + .HasColumnName("last_used"); + entity.Property(e => e.UserId) + .HasColumnName("user"); + + entity.HasOne(d => d.UserNavigation).WithMany(p => p.Sessions) + .HasPrincipalKey(p => p.Id) + .HasForeignKey(d => d.UserId) + .HasConstraintName("user"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("PRIMARY"); + + entity.ToTable("users"); + + entity.HasIndex(e => e.Email, "email").IsUnique(); + + entity.HasIndex(e => e.Username, "username").IsUnique(); + + entity.Property(e => e.Id) + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + entity.Property(e => e.BanReason) + .HasColumnType("text") + .HasColumnName("ban_reason"); + entity.Property(e => e.Banned).HasColumnName("banned"); + entity.Property(e => e.Created) + .HasDefaultValueSql("current_timestamp()") + .HasColumnType("timestamp") + .HasColumnName("created"); + entity.Property(e => e.CvmRank) + .HasDefaultValueSql("'1'") + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank"); + entity.Property(e => e.DateOfBirth).HasColumnName("date_of_birth"); + entity.Property(e => e.Developer).HasColumnName("developer"); + entity.Property(e => e.Email) + .HasColumnType("text") + .HasColumnName("email"); + entity.Property(e => e.EmailVerificationCode) + .HasMaxLength(8) + .IsFixedLength() + .HasColumnName("email_verification_code"); + entity.Property(e => e.EmailVerified).HasColumnName("email_verified"); + entity.Property(e => e.Password) + .HasColumnType("text") + .HasColumnName("password"); + entity.Property(e => e.PasswordResetCode) + .HasMaxLength(8) + .IsFixedLength() + .HasColumnName("password_reset_code"); + entity.Property(e => e.RegistrationIp) + .HasMaxLength(16) + .HasColumnName("registration_ip"); + entity.Property(e => e.Username) + .HasMaxLength(20) + .HasColumnName("username"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/CollabVMAuthServer/Database/DesignTimeCollabVMAuthDbContextFactory.cs b/CollabVMAuthServer/Database/DesignTimeCollabVMAuthDbContextFactory.cs new file mode 100644 index 0000000..18ef84c --- /dev/null +++ b/CollabVMAuthServer/Database/DesignTimeCollabVMAuthDbContextFactory.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Computernewb.CollabVMAuthServer.Database; + +public class DesignTimeCollabVMAuthDbContextFactory : IDesignTimeDbContextFactory { + public CollabVMAuthDbContext CreateDbContext(string[] args) { + return new CollabVMAuthDbContext( + new DbContextOptionsBuilder() + .UseMySql(MariaDbServerVersion.LatestSupportedServerVersion).Options + ); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/Database/LegacyDbMigrator.cs b/CollabVMAuthServer/Database/LegacyDbMigrator.cs new file mode 100644 index 0000000..c36bd12 --- /dev/null +++ b/CollabVMAuthServer/Database/LegacyDbMigrator.cs @@ -0,0 +1,73 @@ +using System; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging; + +namespace Computernewb.CollabVMAuthServer.Database; + +public static class LegacyDbMigrator { + /// + /// The initial database migration that a pre-EF database will already be compatible with + /// + public const string INITIAL_MIGRATION_NAME = "20250505224256_InitialDbModel"; + + /// + /// Checks if a database was initialized by the legacy pre-EF methods. If so, create the migrations table and manually add the initial migration + /// + public static async Task CheckAndMigrate(CollabVMAuthDbContext context) { + var logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger("Computernewb.CollabVMAuthServer.Database.LegacyDbMigrator"); + // Check if initial migration is pending + if ((await context.Database.GetAppliedMigrationsAsync()).Contains(INITIAL_MIGRATION_NAME)) { + logger.LogDebug("Initial migration already applied, skipping legacy db check"); + return; + } + var conn = context.Database.GetDbConnection(); + // Ensure the connection is open + if (conn.State != ConnectionState.Open) { + logger.LogDebug("Opening DB connection"); + await conn.OpenAsync(); + } + // Create command + using var cmd = conn.CreateCommand(); + // Check if meta table exists + cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'meta'"; + // If meta table doesn't exist, db is uninitialized + if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0) { + logger.LogDebug("Database is uninitialized"); + return; + } + // Check database version + cmd.CommandText = "SELECT val FROM meta WHERE setting = 'db_version'"; + var dbVer = (string?) await cmd.ExecuteScalarAsync(); + if (dbVer != "1") { + // 1 was the only version ever used in the old format, so this should not happen + throw new InvalidOperationException($"Invalid database state, cannot automatically migrate (Expected DB version `1`, got `{dbVer}`)"); + } + // Database can be migrated + logger.LogDebug("Legacy database schema detected. Automatically initializing migrations table"); + // Manually create migrations table + var historyRepo = context.Database.GetService(); + cmd.CommandText = historyRepo.GetCreateIfNotExistsScript(); + logger.LogDebug("Migrations table create script: {cmd}", cmd.CommandText); + await cmd.ExecuteNonQueryAsync(); + // Insert row for initial migration + cmd.CommandText = historyRepo.GetInsertScript( + new HistoryRow( + INITIAL_MIGRATION_NAME, + typeof(DbContext).Assembly.GetCustomAttribute()!.InformationalVersion + ) + ); + logger.LogDebug("Migrations table insert script: {cmd}", cmd.CommandText); + await cmd.ExecuteNonQueryAsync(); + logger.LogInformation("Successfully initialized migrations table"); + // Drop meta table + cmd.CommandText = "DROP TABLE meta;"; + await cmd.ExecuteNonQueryAsync(); + logger.LogInformation("Dropped legacy meta table"); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.Designer.cs b/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.Designer.cs new file mode 100644 index 0000000..762776c --- /dev/null +++ b/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.Designer.cs @@ -0,0 +1,275 @@ +// +using System; +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Computernewb.CollabVMAuthServer.Database.Migrations +{ + [DbContext(typeof(CollabVMAuthDbContext))] + [Migration("20250505224256_InitialDbModel")] + partial class InitialDbModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("utf8mb4_unicode_ci") + .HasAnnotation("ProductVersion", "8.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4"); + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("owner"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("char(64)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Owner" }, "owner"); + + b.HasIndex(new[] { "Token" }, "token") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique(); + + b.ToTable("bots", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b => + { + b.Property("Ip") + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("ip"); + + b.Property("BannedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("banned_at") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("BannedBy") + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("banned_by"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Ip") + .HasName("PRIMARY"); + + b.ToTable("ip_bans", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.Property("Token") + .HasMaxLength(32) + .HasColumnType("char(32)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("LastIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("last_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("last_used") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Token") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Username" }, "username"); + + b.ToTable("sessions", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BanReason") + .HasColumnType("text") + .HasColumnName("ban_reason"); + + b.Property("Banned") + .HasColumnType("tinyint(1)") + .HasColumnName("banned"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("Developer") + .HasColumnType("tinyint(1)") + .HasColumnName("developer"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerificationCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("email_verification_code") + .IsFixedLength(); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)") + .HasColumnName("email_verified"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("PasswordResetCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("password_reset_code") + .IsFixedLength(); + + b.Property("RegistrationIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("registration_ip"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Email" }, "email") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation") + .WithMany("Bots") + .HasForeignKey("Owner") + .HasPrincipalKey("Username") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("bots_ibfk_1"); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UsernameNavigation") + .WithMany("Sessions") + .HasForeignKey("Username") + .HasPrincipalKey("Username") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("sessions_ibfk_1"); + + b.Navigation("UsernameNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Navigation("Bots"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.cs b/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.cs new file mode 100644 index 0000000..7954078 --- /dev/null +++ b/CollabVMAuthServer/Database/Migrations/20250505224256_InitialDbModel.cs @@ -0,0 +1,157 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Computernewb.CollabVMAuthServer.Database.Migrations +{ + /// + public partial class InitialDbModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ip_bans", + columns: table => new + { + ip = table.Column(type: "varbinary(16)", maxLength: 16, nullable: false), + reason = table.Column(type: "text", nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + banned_by = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + banned_at = table.Column(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()") + }, + constraints: table => + { + table.PrimaryKey("PRIMARY", x => x.ip); + }) + .Annotation("MySql:CharSet", "utf8mb4") + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "int(10) unsigned", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + username = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + password = table.Column(type: "text", nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + email = table.Column(type: "text", nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + date_of_birth = table.Column(type: "date", nullable: false), + email_verified = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false), + email_verification_code = table.Column(type: "char(8)", fixedLength: true, maxLength: 8, nullable: true, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + password_reset_code = table.Column(type: "char(8)", fixedLength: true, maxLength: 8, nullable: true, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + cvm_rank = table.Column(type: "int(10) unsigned", nullable: false, defaultValueSql: "'1'"), + banned = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false), + ban_reason = table.Column(type: "text", nullable: true, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + registration_ip = table.Column(type: "varbinary(16)", maxLength: 16, nullable: false), + created = table.Column(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"), + developer = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PRIMARY", x => x.id); + table.UniqueConstraint("username", x => x.username); + table.UniqueConstraint("email", x => x.email); + }) + .Annotation("MySql:CharSet", "utf8mb4") + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "bots", + columns: table => new + { + id = table.Column(type: "int(10) unsigned", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + username = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + token = table.Column(type: "char(64)", fixedLength: true, maxLength: 64, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + cvm_rank = table.Column(type: "int(10) unsigned", nullable: false, defaultValueSql: "'1'"), + owner = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + created = table.Column(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()") + }, + constraints: table => + { + table.PrimaryKey("PRIMARY", x => x.id); + table.UniqueConstraint("username", x => x.username); + table.UniqueConstraint("token", x => x.token); + table.ForeignKey( + name: "bots_ibfk_1", + column: x => x.owner, + principalTable: "users", + principalColumn: "username", + onUpdate: ReferentialAction.Cascade, + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4") + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateIndex( + name: "owner", + column: "owner", + table: "bots" + ); + + migrationBuilder.CreateTable( + name: "sessions", + columns: table => new + { + token = table.Column(type: "char(32)", fixedLength: true, maxLength: 32, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + username = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"), + created = table.Column(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"), + last_used = table.Column(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"), + last_ip = table.Column(type: "varbinary(16)", maxLength: 16, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PRIMARY", x => x.token); + table.ForeignKey( + name: "sessions_ibfk_1", + column: x => x.username, + principalTable: "users", + principalColumn: "username", + onUpdate: ReferentialAction.Cascade, + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4") + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateIndex( + name: "username", + column: "username", + table: "sessions" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "bots"); + + migrationBuilder.DropTable( + name: "ip_bans"); + + migrationBuilder.DropTable( + name: "sessions"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.Designer.cs b/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.Designer.cs new file mode 100644 index 0000000..081c389 --- /dev/null +++ b/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.Designer.cs @@ -0,0 +1,270 @@ +// +using System; +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Computernewb.CollabVMAuthServer.Database.Migrations +{ + [DbContext(typeof(CollabVMAuthDbContext))] + [Migration("20250506060015_UseUserIdAsForeignKey")] + partial class UseUserIdAsForeignKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("utf8mb4_unicode_ci") + .HasAnnotation("ProductVersion", "8.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4"); + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("Owner") + .HasColumnType("int(10) unsigned") + .HasColumnName("owner"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("char(64)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Owner" }, "owner"); + + b.HasIndex(new[] { "Token" }, "token") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique(); + + b.ToTable("bots", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b => + { + b.Property("Ip") + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("ip"); + + b.Property("BannedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("banned_at") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("BannedBy") + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("banned_by"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Ip") + .HasName("PRIMARY"); + + b.ToTable("ip_bans", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.Property("Token") + .HasMaxLength(32) + .HasColumnType("char(32)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("LastIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("last_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("last_used") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("UserId") + .HasColumnType("int(10) unsigned") + .HasColumnName("user"); + + b.HasKey("Token") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "UserId" }, "user"); + + b.ToTable("sessions", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BanReason") + .HasColumnType("text") + .HasColumnName("ban_reason"); + + b.Property("Banned") + .HasColumnType("tinyint(1)") + .HasColumnName("banned"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("Developer") + .HasColumnType("tinyint(1)") + .HasColumnName("developer"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerificationCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("email_verification_code") + .IsFixedLength(); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)") + .HasColumnName("email_verified"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("PasswordResetCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("password_reset_code") + .IsFixedLength(); + + b.Property("RegistrationIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("registration_ip"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Email" }, "email") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique() + .HasDatabaseName("username1"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation") + .WithMany("Bots") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("owner"); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UserNavigation") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("user"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Navigation("Bots"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.cs b/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.cs new file mode 100644 index 0000000..e8ad742 --- /dev/null +++ b/CollabVMAuthServer/Database/Migrations/20250506060015_UseUserIdAsForeignKey.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Computernewb.CollabVMAuthServer.Database.Migrations +{ + /// + public partial class UseUserIdAsForeignKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + migrationBuilder.DropForeignKey( + name: "sessions_ibfk_1", + table: "sessions"); + + migrationBuilder.DropForeignKey( + name: "bots_ibfk_1", + table: "bots"); + + migrationBuilder.DropIndex( + name: "username", + table: "sessions"); + + migrationBuilder.DropIndex( + name: "owner", + table: "bots" + ); + + migrationBuilder.RenameColumn( + table: "bots", + name: "owner", + newName: "owner_tmp"); + + migrationBuilder.AddColumn( + name: "user", + table: "sessions", + type: "int(10) unsigned", + nullable: false); + + migrationBuilder.AddColumn( + name: "owner", + table: "bots", + type: "int(10) unsigned", + nullable: false); + + migrationBuilder.CreateIndex( + name: "user", + table: "sessions", + column: "user"); + + migrationBuilder.CreateIndex( + name: "owner", + table: "bots", + column: "owner"); + + // Migrate data + migrationBuilder.Sql( + """ + UPDATE sessions INNER JOIN users ON sessions.username=users.username + SET sessions.user = users.id; + """ + ); + + migrationBuilder.Sql( + """ + UPDATE bots INNER JOIN users ON bots.owner_tmp=users.username + SET bots.owner = users.id; + """ + ); + + migrationBuilder.AddForeignKey( + name: "owner", + table: "bots", + column: "owner", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "user", + table: "sessions", + column: "user", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.DropColumn( + name: "username", + table: "sessions"); + + migrationBuilder.DropColumn( + name: "owner_tmp", + table: "bots"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "owner", + table: "bots"); + + migrationBuilder.DropForeignKey( + name: "user", + table: "sessions"); + + migrationBuilder.DropIndex( + name: "user", + table: "sessions"); + + migrationBuilder.DropColumn( + name: "user", + table: "sessions"); + + migrationBuilder.RenameIndex( + name: "username1", + table: "users", + newName: "username2"); + + migrationBuilder.AddColumn( + name: "username", + table: "sessions", + type: "varchar(20)", + maxLength: 20, + nullable: false, + defaultValue: "", + collation: "utf8mb4_unicode_ci") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AlterColumn( + name: "owner", + table: "bots", + type: "varchar(20)", + maxLength: 20, + nullable: false, + collation: "utf8mb4_unicode_ci", + oldClrType: typeof(uint), + oldType: "int(10) unsigned") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddUniqueConstraint( + name: "AK_users_username", + table: "users", + column: "username"); + + migrationBuilder.CreateIndex( + name: "username1", + table: "sessions", + column: "username"); + + migrationBuilder.AddForeignKey( + name: "bots_ibfk_1", + table: "bots", + column: "owner", + principalTable: "users", + principalColumn: "username", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "sessions_ibfk_1", + table: "sessions", + column: "username", + principalTable: "users", + principalColumn: "username", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/CollabVMAuthServer/Database/Migrations/CollabVMAuthDbContextModelSnapshot.cs b/CollabVMAuthServer/Database/Migrations/CollabVMAuthDbContextModelSnapshot.cs new file mode 100644 index 0000000..ce45e84 --- /dev/null +++ b/CollabVMAuthServer/Database/Migrations/CollabVMAuthDbContextModelSnapshot.cs @@ -0,0 +1,267 @@ +// +using System; +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Computernewb.CollabVMAuthServer.Database.Migrations +{ + [DbContext(typeof(CollabVMAuthDbContext))] + partial class CollabVMAuthDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("utf8mb4_unicode_ci") + .HasAnnotation("ProductVersion", "8.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4"); + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("Owner") + .HasColumnType("int(10) unsigned") + .HasColumnName("owner"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("char(64)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Owner" }, "owner"); + + b.HasIndex(new[] { "Token" }, "token") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique(); + + b.ToTable("bots", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b => + { + b.Property("Ip") + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("ip"); + + b.Property("BannedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("banned_at") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("BannedBy") + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("banned_by"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Ip") + .HasName("PRIMARY"); + + b.ToTable("ip_bans", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.Property("Token") + .HasMaxLength(32) + .HasColumnType("char(32)") + .HasColumnName("token") + .IsFixedLength(); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("LastIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("last_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("last_used") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("UserId") + .HasColumnType("int(10) unsigned") + .HasColumnName("user"); + + b.HasKey("Token") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "UserId" }, "user"); + + b.ToTable("sessions", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BanReason") + .HasColumnType("text") + .HasColumnName("ban_reason"); + + b.Property("Banned") + .HasColumnType("tinyint(1)") + .HasColumnName("banned"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp") + .HasColumnName("created") + .HasDefaultValueSql("current_timestamp()"); + + b.Property("CvmRank") + .ValueGeneratedOnAdd() + .HasColumnType("int(10) unsigned") + .HasColumnName("cvm_rank") + .HasDefaultValueSql("'1'"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("Developer") + .HasColumnType("tinyint(1)") + .HasColumnName("developer"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerificationCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("email_verification_code") + .IsFixedLength(); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)") + .HasColumnName("email_verified"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("PasswordResetCode") + .HasMaxLength(8) + .HasColumnType("char(8)") + .HasColumnName("password_reset_code") + .IsFixedLength(); + + b.Property("RegistrationIp") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varbinary(16)") + .HasColumnName("registration_ip"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("PRIMARY"); + + b.HasIndex(new[] { "Email" }, "email") + .IsUnique(); + + b.HasIndex(new[] { "Username" }, "username") + .IsUnique() + .HasDatabaseName("username1"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation") + .WithMany("Bots") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("bots_ibfk_1"); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b => + { + b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UserNavigation") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("sessions_ibfk_1"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b => + { + b.Navigation("Bots"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CollabVMAuthServer/Database/Schema/Bot.cs b/CollabVMAuthServer/Database/Schema/Bot.cs new file mode 100644 index 0000000..47a2dab --- /dev/null +++ b/CollabVMAuthServer/Database/Schema/Bot.cs @@ -0,0 +1,20 @@ +using System; + +namespace Computernewb.CollabVMAuthServer.Database.Schema; + +public partial class Bot +{ + public uint Id { get; set; } + + public string Username { get; set; } = null!; + + public string Token { get; set; } = null!; + + public uint CvmRank { get; set; } + + public uint Owner { get; set; } + + public DateTime Created { get; set; } + + public virtual User OwnerNavigation { get; set; } = null!; +} diff --git a/CollabVMAuthServer/Database/Schema/IpBan.cs b/CollabVMAuthServer/Database/Schema/IpBan.cs new file mode 100644 index 0000000..d04642b --- /dev/null +++ b/CollabVMAuthServer/Database/Schema/IpBan.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Computernewb.CollabVMAuthServer.Database.Schema; + +public partial class IpBan +{ + public byte[] Ip { get; set; } = null!; + + public string Reason { get; set; } = null!; + + public string? BannedBy { get; set; } + + public DateTime BannedAt { get; set; } +} diff --git a/CollabVMAuthServer/Database/Schema/Rank.cs b/CollabVMAuthServer/Database/Schema/Rank.cs new file mode 100644 index 0000000..28059c1 --- /dev/null +++ b/CollabVMAuthServer/Database/Schema/Rank.cs @@ -0,0 +1,7 @@ +namespace Computernewb.CollabVMAuthServer.Database.Schema; + +public enum Rank : uint { + Registered = 1, + Admin = 2, + Moderator = 3 +} \ No newline at end of file diff --git a/CollabVMAuthServer/Database/Schema/Session.cs b/CollabVMAuthServer/Database/Schema/Session.cs new file mode 100644 index 0000000..6401487 --- /dev/null +++ b/CollabVMAuthServer/Database/Schema/Session.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Computernewb.CollabVMAuthServer.Database.Schema; + +public partial class Session +{ + public string Token { get; set; } = null!; + + public uint UserId { get; set; } + + public DateTime Created { get; set; } + + public DateTime LastUsed { get; set; } + + public byte[] LastIp { get; set; } = null!; + + public virtual User UserNavigation { get; set; } = null!; +} diff --git a/CollabVMAuthServer/Database/Schema/User.cs b/CollabVMAuthServer/Database/Schema/User.cs new file mode 100644 index 0000000..bb1a72d --- /dev/null +++ b/CollabVMAuthServer/Database/Schema/User.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Computernewb.CollabVMAuthServer.Database.Schema; + +public partial class User +{ + public uint Id { get; set; } + + public string Username { get; set; } = null!; + + public string Password { get; set; } = null!; + + public string Email { get; set; } = null!; + + public DateOnly DateOfBirth { get; set; } + + public bool EmailVerified { get; set; } + + public string? EmailVerificationCode { get; set; } + + public string? PasswordResetCode { get; set; } + + public uint CvmRank { get; set; } + + public bool Banned { get; set; } + + public string? BanReason { get; set; } + + public byte[] RegistrationIp { get; set; } = null!; + + public DateTime Created { get; set; } + + public bool Developer { get; set; } + + public virtual ICollection Bots { get; set; } = new List(); + + public virtual ICollection Sessions { get; set; } = new List(); +} diff --git a/CollabVMAuthServer/DatabaseUpdate.cs b/CollabVMAuthServer/DatabaseUpdate.cs deleted file mode 100644 index 8255b57..0000000 --- a/CollabVMAuthServer/DatabaseUpdate.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Threading.Tasks; - -namespace Computernewb.CollabVMAuthServer; - -public static class DatabaseUpdate -{ - public const int CurrentVersion = 1; - - private static ReadOnlyDictionary> Updates = new Dictionary>() - { - { 1, async db => - { - // Update to version 1 - // Add ban_reason column to users table - await db.ExecuteNonQuery("ALTER TABLE users ADD COLUMN ban_reason TEXT DEFAULT NULL"); - }}, - }.AsReadOnly(); - - public async static Task Update(Database db) - { - var version = await db.GetDatabaseVersion(); - if (version == -1) throw new InvalidOperationException("Uninitialized database cannot be updated"); - if (version == CurrentVersion) return; - if (version > CurrentVersion) throw new InvalidOperationException("Database version is newer than the server supports"); - Utilities.Log(LogLevel.INFO, $"Updating database from version {version} to {CurrentVersion}"); - for (int i = version + 1; i <= CurrentVersion; i++) - { - if (!Updates.TryGetValue(i, out var update)) throw new InvalidOperationException($"No update available for version {i}"); - await update(db); - } - await db.SetDatabaseVersion(CurrentVersion); - } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/AdminRoutes.cs b/CollabVMAuthServer/HTTP/AdminRoutes.cs deleted file mode 100644 index 0dc53ca..0000000 --- a/CollabVMAuthServer/HTTP/AdminRoutes.cs +++ /dev/null @@ -1,503 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Text.Json; -using System.Threading.Tasks; -using Computernewb.CollabVMAuthServer.HTTP.Payloads; -using Computernewb.CollabVMAuthServer.HTTP.Responses; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Computernewb.CollabVMAuthServer.HTTP; - -public static class AdminRoutes -{ - public static void RegisterRoutes(IEndpointRouteBuilder app) - { - app.MapPost("/api/v1/admin/users", (Delegate)HandleAdminUsers); - app.MapPost("/api/v1/admin/updateuser", (Delegate)HandleAdminUpdateUser); - app.MapPost("/api/v1/admin/updatebot", (Delegate)HandleAdminUpdateBot); - app.MapPost("/api/v1/admin/ban", (Delegate)HandleBanUser); - app.MapPost("/api/v1/admin/ipban", (Delegate)HandleIPBan); - } - - private static async Task HandleIPBan(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - IPBanPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.session) || string.IsNullOrWhiteSpace(payload.ip) || (payload.banned && string.IsNullOrWhiteSpace(payload.reason)) || payload.banned == null || !IPAddress.TryParse(payload.ip, out var ip)) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var user = await Utilities.GetStaffByToken(payload.session); - if (user == null) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check rank - if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new IPBanResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Set ban - if (payload.banned) - { - await Program.Database.BanIP(ip, payload.reason, user.Username); - } - else - { - await Program.Database.UnbanIP(ip); - } - return Results.Json(new IPBanResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleBanUser(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new BanUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - BanUserPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new BanUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username) || (payload.banned && string.IsNullOrWhiteSpace(payload.reason)) || payload.banned == null) - { - context.Response.StatusCode = 400; - return Results.Json(new BanUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var user = await Utilities.GetStaffByToken(payload.token); - if (user == null) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check rank - if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new IPBanResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Check target user - var targetUser = await Program.Database.GetUser(payload.username); - if (targetUser == null) - { - context.Response.StatusCode = 400; - return Results.Json(new BanUserResponse - { - success = false, - error = "User not found" - }, Utilities.JsonSerializerOptions); - } - // Set ban - await Program.Database.SetBanned(targetUser.Username, payload.banned, payload.banned ? payload.reason : null); - return Results.Json(new BanUserResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleAdminUpdateBot(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - AdminUpdateBotPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var user = await Utilities.GetStaffByToken(payload.token); - if (user == null) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check rank - if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new IPBanResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Check target bot - var targetBot = await Program.Database.GetBot(payload.username); - if (targetBot == null) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Bot not found" - }, Utilities.JsonSerializerOptions); - } - // Make sure at least one field is being updated - if (payload.rank == null) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "No fields to update" - }, Utilities.JsonSerializerOptions); - } - // Moderators cannot promote bots to admin, and can only promote their own bots to moderator - else if ((Rank)payload.rank == Rank.Admin && user.Rank == Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - if (targetBot.Owner != user.Username && user.Rank == Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Check rank - int? rank = payload.rank; - if (rank != null && rank < 1 || rank > 3) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateBotResponse - { - success = false, - error = "Invalid rank" - }, Utilities.JsonSerializerOptions); - } - // Update rank - await Program.Database.UpdateBot(targetBot.Username, newRank: payload.rank); - return Results.Json(new AdminUpdateBotResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleAdminUpdateUser(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - AdminUpdateUserPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var user = await Utilities.GetStaffByToken(payload.token); - if (user == null) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check rank - if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new IPBanResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Check target user - var targetUser = await Program.Database.GetUser(payload.username); - if (targetUser == null) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "User not found" - }, Utilities.JsonSerializerOptions); - } - // Check rank - int? rank = payload.rank; - if (rank != null && rank < 1 || rank > 3) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "Invalid rank" - }, Utilities.JsonSerializerOptions); - } - // Moderators cannot change ranks - if (user.Rank == Rank.Moderator && rank != null) - { - context.Response.StatusCode = 403; - return Results.Json(new AdminUpdateUserResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Check developer - bool? developer = payload.developer; - // Update rank - await Program.Database.UpdateUser(targetUser.Username, newRank: payload.rank, developer: developer); - if (developer == false) - { - await Program.Database.DeleteBots(targetUser.Username); - } - return Results.Json(new AdminUpdateUserResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleAdminUsers(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUsersResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - AdminUsersPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new AdminUsersResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || payload.page < 1 || payload.resultsPerPage < 1) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUsersResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var user = await Utilities.GetStaffByToken(payload.token); - if (user == null) - { - context.Response.StatusCode = 400; - return Results.Json(new IPBanResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check rank - if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new IPBanResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Validate orderBy - if (payload.orderBy != null && !new string[] { "id", "username", "email", "date_of_birth", "cvm_rank", "banned", "created" }.Contains(payload.orderBy)) - { - context.Response.StatusCode = 400; - return Results.Json(new AdminUsersResponse - { - success = false, - error = "Invalid orderBy" - }, Utilities.JsonSerializerOptions); - } - // Get users - string? filterUsername = null; - if (payload.filterUsername != null) - { - filterUsername = "%" + payload.filterUsername - .Replace("%", "!%") - .Replace("!", "!!") - .Replace("_", "!_") - .Replace("[", "![") + "%"; - } - IPAddress? filterIp = null; - if (payload.filterIp != null) - { - if (!IPAddress.TryParse(payload.filterIp, out filterIp)) { - context.Response.StatusCode = 400; - return Results.Json(new AdminUsersResponse - { - success = false, - error = "Invalid filterIp" - }, Utilities.JsonSerializerOptions); - } - } - - var users = (await Program.Database.ListUsers(filterUsername, filterIp, payload.orderBy ?? "id", payload.orderByDescending)).Select(user => new AdminUser - { - id = user.Id, - username = user.Username, - email = user.Email, - rank = (int)user.Rank, - banned = user.Banned, - banReason = user.BanReason ?? "", - dateOfBirth = user.DateOfBirth.ToString("yyyy-MM-dd"), - dateJoined = user.Joined.ToString("yyyy-MM-dd HH:mm:ss"), - registrationIp = user.RegistrationIP.ToString(), - developer = user.Developer - }).ToArray(); - var page = users.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage).ToArray(); - return Results.Json(new AdminUsersResponse - { - success = true, - users = page, - totalPageCount = (int)Math.Ceiling(users.Length / (double)payload.resultsPerPage) - }, Utilities.JsonSerializerOptions); - } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/CollabVMAuthenticationHandler.cs b/CollabVMAuthServer/HTTP/CollabVMAuthenticationHandler.cs new file mode 100644 index 0000000..ad0893f --- /dev/null +++ b/CollabVMAuthServer/HTTP/CollabVMAuthenticationHandler.cs @@ -0,0 +1,149 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; +using Computernewb.CollabVMAuthServer.Database.Schema; +using Computernewb.CollabVMAuthServer.HTTP.Payloads; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Computernewb.CollabVMAuthServer.HTTP; + +public partial class CollabVMAuthenticationHandler : SignInAuthenticationHandler +{ + public CollabVMAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) + { + } + + + [GeneratedRegex("^Session (?.+)$")] + private static partial Regex AuthorizationHeaderRegex(); + + private string? GetSessionTokenFromAuthorizationHeader() { + // Check for Authorization header + var authorizationHeader = Context.Request.Headers.Authorization.ToString(); + if (AuthorizationHeaderRegex().Match(authorizationHeader).Groups.TryGetValue("token", out var match)) { + return match.Value; + } else { + return null; + } + } + + private string? GetSessionTokenFromCookie() { + if (Context.Request.Cookies.TryGetValue("collabvm_session", out var token)) { + return token; + } else { + return null; + } + } + + private async Task GetSessionTokenFromBody() { + // This is how the current webapp sends the token. + // I really do not like this. Should be changed to use authorization or cookie and then we can eventually axe this + if (Context.Request.ContentType != "application/json") { + return null; + } + Context.Request.EnableBuffering(); + var payload = await Context.Request.ReadFromJsonAsync(); + // sigh + Context.Request.Body.Position = 0; + // This can be two different keys because I was on crack cocaine when I wrote the original API + return + payload?.Session ?? + payload?.Token; + } + + + protected override async Task HandleAuthenticateAsync() + { + // There are multiple ways the client can send a session token. We check them in order of preference + var sessionToken = + GetSessionTokenFromAuthorizationHeader() ?? + GetSessionTokenFromCookie() ?? + await GetSessionTokenFromBody(); + + // If no session token was provided, fail + if (sessionToken == null) { + return AuthenticateResult.NoResult(); + } + + // Open db and find session + using var dbContext = new CollabVMAuthDbContext(Options.DbContextOptions); + Claim[] claims = []; + + if (sessionToken.Length == 32) { // User + var session = await dbContext.Sessions.Include(s => s.UserNavigation).FirstOrDefaultAsync(s => s.Token == sessionToken); + + // Fail if invalid token or expired + if (session == null || DateTime.UtcNow > session.LastUsed.AddDays(Program.Config.Accounts!.SessionExpiryDays)) { + return AuthenticateResult.NoResult(); + } + + claims = [ + new("type", "user"), + new("id", session.UserNavigation.Id.ToString()), + new("username", session.UserNavigation.Username), + new("rank", session.UserNavigation.CvmRank.ToString()), + new("developer", session.UserNavigation.Developer ? "1" : "0") + ]; + } else if (sessionToken.Length == 64) { // Bot + var bot = await dbContext.Bots.FirstOrDefaultAsync(b => b.Token == sessionToken); + + // Fail if unknown bot + if (bot == null) { + return AuthenticateResult.NoResult(); + } + + claims = [ + new("type", "bot"), + new("id", bot.Id.ToString()), + new("username", bot.Username), + new("rank", bot.CvmRank.ToString()) + ]; + } else { + return AuthenticateResult.NoResult(); + } + + // Return success result + return AuthenticateResult.Success( + new AuthenticationTicket( + new ClaimsPrincipal( + new ClaimsIdentity(claims, Scheme.Name) + ), + Scheme.Name + ) + ); + } + + protected override async Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) + { + var token = Utilities.RandomString(32); + using var dbContext = new CollabVMAuthDbContext(Options.DbContextOptions); + // Add to database + await dbContext.Sessions.AddAsync(new Session { + Token = token, + UserId = uint.Parse(user.FindFirstValue("id") + ?? throw new InvalidOperationException("User ID claim was null")), + Created = DateTime.UtcNow, + LastUsed = DateTime.UtcNow, + LastIp = Context.Connection.RemoteIpAddress!.GetAddressBytes(), + }); + await dbContext.SaveChangesAsync(); + // Add claim + user.Identities.First().AddClaim(new("token", token)); + // Set cookie + Context.Response.Cookies.Append("collabvm_session", token); + } + + protected override Task HandleSignOutAsync(AuthenticationProperties? properties) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/CollabVMAuthenticationSchemeOptions.cs b/CollabVMAuthServer/HTTP/CollabVMAuthenticationSchemeOptions.cs new file mode 100644 index 0000000..809129c --- /dev/null +++ b/CollabVMAuthServer/HTTP/CollabVMAuthenticationSchemeOptions.cs @@ -0,0 +1,9 @@ +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; + +namespace Computernewb.CollabVMAuthServer.HTTP; + +public class CollabVMAuthenticationSchemeOptions : AuthenticationSchemeOptions { + public DbContextOptions DbContextOptions { get; set; } = new(); +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/CollabVMAuthorizationMiddlewareResultHandler.cs b/CollabVMAuthServer/HTTP/CollabVMAuthorizationMiddlewareResultHandler.cs new file mode 100644 index 0000000..1cb4144 --- /dev/null +++ b/CollabVMAuthServer/HTTP/CollabVMAuthorizationMiddlewareResultHandler.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.HTTP.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http; + +namespace Computernewb.CollabVMAuthServer.HTTP; + +public class CollabVMAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler defaultHandler = new(); + public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult) + { + if (authorizeResult.Forbidden) { + context.Response.StatusCode = 403; + var requirement = authorizeResult.AuthorizationFailure!.FailedRequirements.First(); + if (requirement is ClaimsAuthorizationRequirement req) { + if (req.ClaimType == "rank") { + await context.Response.WriteAsJsonAsync(new ApiResponse { + success = false, + error = "You do not have the correct rank to do that." + }); + return; + } else if (req.ClaimType == "developer") { + await context.Response.WriteAsJsonAsync(new ApiResponse { + success = false, + error = "You must be a developer to do that." + }); + return; + } + } + await context.Response.WriteAsJsonAsync(new ApiResponse { + success = false, + error = "Access forbidden." + }); + return; + } else if (authorizeResult.Challenged) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new ApiResponse { + success = false, + error = "You need to login to do that." + }); + return; + } + + // Fall back to default handler + await defaultHandler.HandleAsync(next, context, policy, authorizeResult); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Controllers/AdminApiController.cs b/CollabVMAuthServer/HTTP/Controllers/AdminApiController.cs new file mode 100644 index 0000000..2d42890 --- /dev/null +++ b/CollabVMAuthServer/HTTP/Controllers/AdminApiController.cs @@ -0,0 +1,308 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; +using Computernewb.CollabVMAuthServer.Database.Schema; +using Computernewb.CollabVMAuthServer.HTTP.Payloads; +using Computernewb.CollabVMAuthServer.HTTP.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Computernewb.CollabVMAuthServer.HTTP.Controllers; + +[Route("api/v1/admin")] +[ApiController] +public class AdminApiController : ControllerBase +{ + private readonly CollabVMAuthDbContext _dbContext; + public AdminApiController(CollabVMAuthDbContext dbContext) { + this._dbContext = dbContext; + } + + [HttpPost] + [Route("ipban")] + [Authorize("Staff")] + public async Task HandleIPBan(IPBanPayload payload) + { + var ip = IPAddress.Parse(payload.ip).GetAddressBytes(); + // Find or create ban + var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == ip); + + if (payload.banned) + { + ban ??= new IpBan { Ip = ip }; + ban.Reason = payload.reason; + ban.BannedBy = HttpContext.User.FindFirstValue("username"); + ban.BannedAt = DateTime.UtcNow; + } + else + { + if (ban == null) { + return Results.Json(new ApiResponse { + success = false, + error = "IP is not banned." + }, statusCode: 400); + } + _dbContext.IpBans.Remove(ban); + } + await _dbContext.SaveChangesAsync(); + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("ban")] + [Authorize("Staff")] + public async Task HandleBanUser(BanUserPayload payload) + { + // Check target user + var targetUser = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username); + if (targetUser == null) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "User not found" + }); + } + // Set ban + targetUser.Banned = payload.banned; + targetUser.BanReason = payload.reason; + await _dbContext.SaveChangesAsync(); + + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("updatebot")] + [Authorize("Staff")] + public async Task HandleAdminUpdateBot(AdminUpdateBotPayload payload) + { + // Check target bot + var targetBot = await _dbContext.Bots.FirstOrDefaultAsync(b => b.Username == payload.username); + if (targetBot == null) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Bot not found" + }); + } + // Make sure at least one field is being updated + if (payload.rank == null) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "No fields to update" + }); + } + // Moderators cannot promote bots to admin, and can only promote their own bots to moderator + if ((Rank)payload.rank == Rank.Admin && HttpContext.User.FindFirstValue("rank") == "3") + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new ApiResponse + { + success = false, + error = "Insufficient permissions" + }); + } + if (targetBot.Owner != uint.Parse(HttpContext.User.FindFirstValue("id")!) && HttpContext.User.FindFirstValue("rank") == "3") + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new ApiResponse + { + success = false, + error = "Insufficient permissions" + }); + } + // Check rank + uint? rank = payload.rank; + if (rank != null) { + if (rank < 1 || rank > 3) { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid rank" + }); + } + targetBot.CvmRank = payload.rank.Value; + } + + // Update + await _dbContext.SaveChangesAsync(); + + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("updateuser")] + [Authorize("Staff")] + public async Task HandleAdminUpdateUser(AdminUpdateUserPayload payload) + { + // Check target user + var targetUser = await _dbContext.Users.Include(u => u.Bots).FirstOrDefaultAsync(u => u.Username == payload.username); + if (targetUser == null) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "User not found" + }); + } + // Check rank + uint? rank = payload.rank; + if (rank != null) { + if (rank < 1 || rank > 3) { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid rank" + }); + } + + // Moderators cannot change ranks + if (HttpContext.User.FindFirstValue("rank") == "3") { + HttpContext.Response.StatusCode = 403; + return Results.Json(new ApiResponse + { + success = false, + error = "Insufficient permissions" + }); + } + + targetUser.CvmRank = rank.Value; + } + // Check developer + if (payload.developer != null) { + targetUser.Developer = payload.developer.Value; + } + + if (targetUser.Developer == false) { + targetUser.Bots.Clear(); + } + + // Update + await _dbContext.SaveChangesAsync(); + + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("users")] + [Authorize("Staff")] + public async Task HandleAdminUsers(AdminUsersPayload payload) + { + // Validate orderBy + if (payload.orderBy != null && !new string[] { "id", "username", "email", "date_of_birth", "cvm_rank", "banned", "created" }.Contains(payload.orderBy)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new AdminUsersResponse + { + success = false, + error = "Invalid orderBy" + }); + } + // Filter IP + IPAddress? filterIp = null; + if (payload.filterIp != null) + { + if (!IPAddress.TryParse(payload.filterIp, out filterIp)) { + HttpContext.Response.StatusCode = 400; + return Results.Json(new AdminUsersResponse + { + success = false, + error = "Invalid filterIp" + }); + } + } + + // Get users + IQueryable result = _dbContext.Users; + + if (payload.filterUsername != null) { + result = result.Where(u => u.Username.Contains(payload.filterUsername)); + } + + if (filterIp != null) { + result = result.Where(u => u.RegistrationIp == filterIp.GetAddressBytes()); + } + + var orderBy = payload.orderBy ?? "id"; + var order = (Expression> k) => { + if (payload.orderByDescending) { + result = result.OrderByDescending(k); + } else { + result = result.OrderBy(k); + } + }; + switch (orderBy) { + case "id": + order(u => u.Id); + break; + case "username": + order(u => u.Username); + break; + case "email": + order(u => u.Email); + break; + case "date_of_birth": + order(u => u.DateOfBirth); + break; + case "cvm_rank": + order(u => u.CvmRank); + break; + case "banned": + order(u => u.Banned); + break; + case "created": + order(u => u.Created); + break; + } + + result = result.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage); + + var users = await result.Select(user => new AdminUser + { + id = user.Id, + username = user.Username, + email = user.Email, + rank = user.CvmRank, + banned = user.Banned, + banReason = user.BanReason ?? "", + dateOfBirth = user.DateOfBirth.ToString("yyyy-MM-dd"), + dateJoined = user.Created.ToString("yyyy-MM-dd HH:mm:ss"), + registrationIp = new IPAddress(user.RegistrationIp).ToString(), + developer = user.Developer + }).ToArrayAsync(); + + return Results.Json(new AdminUsersResponse + { + success = true, + users = users, + totalPageCount = (int)Math.Ceiling(await _dbContext.Users.CountAsync() / (double)payload.resultsPerPage) + }); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Controllers/AuthenticationApiController.cs b/CollabVMAuthServer/HTTP/Controllers/AuthenticationApiController.cs new file mode 100644 index 0000000..3e0998d --- /dev/null +++ b/CollabVMAuthServer/HTTP/Controllers/AuthenticationApiController.cs @@ -0,0 +1,754 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; +using Computernewb.CollabVMAuthServer.Database.Schema; +using Computernewb.CollabVMAuthServer.HTTP.Payloads; +using Computernewb.CollabVMAuthServer.HTTP.Responses; +using Isopoh.Cryptography.Argon2; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Computernewb.CollabVMAuthServer.HTTP.Controllers; + +[Route("api/v1")] +[ApiController] +public class AuthenticationApiController : ControllerBase +{ + private readonly CollabVMAuthDbContext _dbContext; + public AuthenticationApiController(CollabVMAuthDbContext dbContext) { + this._dbContext = dbContext; + } + + [HttpPost] + [Route("sendreset")] + public async Task HandleSendReset(SendResetEmailPayload payload) + { + if (!Program.Config.SMTP!.Enabled) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Password reset is not supported by this server. Please contact an administrator." + }); + } + + // Check captcha response + if (Program.Config.hCaptcha!.Enabled) + { + if (string.IsNullOrWhiteSpace(payload.captchaToken)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Missing hCaptcha token" + }); + } + var result = + await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString()); + if (!result.success) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid captcha response" + }); + } + } + // Check username and E-Mail + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username); + if (user == null || user.Email != payload.email) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid username or E-Mail" + }); + } + // Generate reset code + var code = Program.Random.Next(10000000, 99999999).ToString(); + user.PasswordResetCode = code; + await _dbContext.SaveChangesAsync(); + await Program.Mailer!.SendPasswordResetEmail(payload.username, payload.email, code); + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("reset")] + public async Task HandleReset(ResetPasswordPayload payload) + { + // Is mailer enabled? + if (Program.Mailer == null) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Password reset is disabled" + }); + } + // Check username and E-Mail + var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Username == payload.username); + if (user == null || user.Email != payload.email) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid username or E-Mail" + }); + } + // Check if code is correct + if (user.PasswordResetCode != payload.code) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Invalid reset code" + }); + } + // Validate new password + if (!Utilities.ValidatePassword(payload.newPassword)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." + }); + } + if (Program.BannedPasswords.Contains(payload.newPassword)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new ApiResponse + { + success = false, + error = "That password is commonly used and is not allowed." + }); + } + // Reset password + var newPasswordHashed = Argon2.Hash(payload.newPassword); + user.Password = newPasswordHashed; + user.PasswordResetCode = null; + user.Sessions.Clear(); + await _dbContext.SaveChangesAsync(); + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("update")] + [Authorize("User")] + public async Task HandleUpdate(UpdatePayload payload) + { + var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Id == uint.Parse(HttpContext.User.FindFirstValue("id")!)); + // Check password + if (!Argon2.Verify(user!.Password, payload.currentPassword)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "Invalid password", + }); + } + // Validate new username + if (payload.username != null) + { + if (!Utilities.ValidateUsername(payload.username)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." + }); + } + // Make sure username isn't taken + if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) || await _dbContext.Bots.AnyAsync(b => b.Username == payload.username)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That username is taken." + }); + } + user.Username = payload.username; + } + // Validate new E-Mail + if (payload.email != null) + { + if (!new EmailAddressAttribute().IsValid(payload.email)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "Malformed E-Mail address." + }); + } + if (Program.Config.Registration!.EmailDomainWhitelist && !Program.Config.Registration!.AllowedEmailDomains!.Contains(payload.email.Split("@")[1])) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "That E-Mail domain is not allowed." + }); + } + // Check if E-Mail is in use + if (_dbContext.Users.Any(u => u.Email == payload.email)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That E-Mail is already in use." + }); + } + user.Email = payload.email; + } + // Validate new password + if (payload.newPassword != null) + { + if (!Utilities.ValidatePassword(payload.newPassword)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." + }); + } + if (Program.BannedPasswords.Contains(payload.newPassword)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "That password is commonly used and is not allowed." + }); + } + user.Password = Argon2.Hash(payload.newPassword); + } + // Revoke all sessions + user.Sessions.Clear(); + // Unverify the account if the E-Mail was changed + if (payload.email != null && Program.Config.Registration!.EmailVerificationRequired) + { + user.EmailVerified = false; + user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString(); + await Program.Mailer!.SendVerificationCode(user.Username, payload.email, user.EmailVerificationCode); + } + // Save changes + await _dbContext.SaveChangesAsync(); + return Results.Json(new UpdateResponse + { + success = true, + verificationRequired = !user.EmailVerified, + sessionExpired = true + }); + } + + [HttpPost] + [Route("logout")] + [Authorize("User")] + public async Task HandleLogout() + { + var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Id == uint.Parse(HttpContext.User.FindFirstValue("id")!)); + // Revoke session + user!.Sessions.Clear(); + await _dbContext.SaveChangesAsync(); + + return Results.Json(new ApiResponse + { + success = true + }); + } + + [HttpPost] + [Route("session")] + [Authorize("User")] + public async Task HandleSession() + { + var user = await _dbContext.Users.FindAsync(uint.Parse(HttpContext.User.FindFirstValue("id")!)); + return Results.Json(new SessionResponse + { + success = true, + banned = user!.Banned, + username = user.Username, + email = user.Email, + rank = user.CvmRank, + developer = user.Developer + }); + } + + [HttpPost] + [Route("join")] + public async Task HandleJoin(JoinPayload payload) + { + // Check secret key + if (payload.secretKey != Program.Config.CollabVM!.SecretKey) + { + HttpContext.Response.StatusCode = 401; + return Results.Json(new JoinResponse + { + success = false, + error = "Invalid secret key" + }); + } + // Check if IP banned + if (!IPAddress.TryParse(payload.ip, out var ip)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new JoinResponse + { + success = false, + error = "Malformed IP address" + }); + } + var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == ip.GetAddressBytes()); + if (ban != null) + { + HttpContext.Response.StatusCode = 200; + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + error = "Banned", + banned = true, + banReason = ban.Reason + }); + } + // Check if session is valid + if (payload.sessionToken.Length == 32) + { + // User + var session = await _dbContext.Sessions.Include(s => s.UserNavigation).FirstOrDefaultAsync(s => s.Token == payload.sessionToken); + if (session == null) + { + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + error = "Invalid session", + }); + } + // Check if session is expired + if (DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts!.SessionExpiryDays)) + { + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + error = "Invalid session", + }); + } + // Check if banned + if (session.UserNavigation.Banned) + { + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + banned = true, + error = "Banned", + banReason = session.UserNavigation.BanReason + }); + } + // Update session + session.LastUsed = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = true, + username = session.UserNavigation.Username, + rank = session.UserNavigation.CvmRank + }); + } else if (payload.sessionToken.Length == 64) + { + // Bot + var bot = await _dbContext.Bots.FirstOrDefaultAsync(b => b.Token == payload.sessionToken); + if (bot == null) + { + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + error = "Invalid session", + }); + } + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = true, + username = bot.Username, + rank = bot.CvmRank + }); + } + else + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new JoinResponse + { + success = true, + clientSuccess = false, + error = "Invalid session" + }); + } + } + + [HttpPost] + [Route("login")] + public async Task HandleLogin(LoginPayload payload) + { + // Check captcha response + if (Program.Config.hCaptcha!.Enabled) + { + if (string.IsNullOrWhiteSpace(payload.captchaToken)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new LoginResponse + { + success = false, + error = "Missing hCaptcha token" + }); + } + var result = + await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString()); + if (!result.success) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new LoginResponse + { + success = false, + error = "Invalid captcha response" + }); + } + } + // Validate username and password + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username); + if (user == null || !Argon2.Verify(user.Password, payload.password)) + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new LoginResponse + { + success = false, + error = "Invalid username or password" + }); + } + // Check if IP banned + var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == HttpContext.Connection.RemoteIpAddress!.GetAddressBytes()); + if (ban != null) + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new LoginResponse + { + success = false, + error = $"You are banned: {ban.Reason}" + }); + } + // Check if account is verified + if (!user.EmailVerified && Program.Config.Registration!.EmailVerificationRequired) + { + if (user.EmailVerificationCode == null) { + user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString(); + await _dbContext.SaveChangesAsync(); + await Program.Mailer!.SendVerificationCode(user.Username, user.Email, user.EmailVerificationCode); + } + return Results.Json(new LoginResponse + { + success = true, + verificationRequired = true, + email = user.Email, + username = user.Username, + rank = user.CvmRank, + developer = user.Developer + }); + } + // Check max sessions + var sessions = await _dbContext.Sessions.Include(s => s.UserNavigation).CountAsync(s => s.UserNavigation.Username == user.Username); + if (sessions >= Program.Config.Accounts!.MaxSessions) + { + var oldest = await _dbContext.Sessions.Include(s => s.UserNavigation).Where(s => s.UserNavigation.Username == user.Username).OrderBy(s => s.LastUsed).FirstAsync(); + _dbContext.Sessions.Remove(oldest); + await _dbContext.SaveChangesAsync(); + } + // Perform sign-in + var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())])); + await HttpContext.SignInAsync(userPrincipal); + var token = userPrincipal.FindFirstValue("token") + ?? throw new InvalidOperationException("Sign in handler did not add token"); + return Results.Json(new LoginResponse + { + success = true, + token = token, + username = user.Username, + email = user.Email, + rank = user.CvmRank, + developer = user.Developer + }); + } + + [HttpPost] + [Route("verify")] + public async Task HandleVerify(VerifyPayload payload) + { + // Validate username and password + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username); + if (user == null || !Argon2.Verify(user.Password, payload.password)) + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new VerifyResponse + { + success = false, + error = "Invalid username or password" + }); + } + // Check if account is verified + if (user.EmailVerified) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new VerifyResponse + { + success = false, + error = "Account is already verified" + }); + } + // Check if code is correct + if (user.EmailVerificationCode != payload.code) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new VerifyResponse + { + success = false, + error = "Invalid verification code" + }); + } + // Verify the account + user.EmailVerified = true; + // Create a session + var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())])); + await HttpContext.SignInAsync(userPrincipal); + var token = userPrincipal.FindFirstValue("token") + ?? throw new InvalidOperationException("Sign in handler did not add token"); + await _dbContext.SaveChangesAsync(); + return Results.Json(new VerifyResponse + { + success = true, + sessionToken = token, + }); + } + + [HttpPost] + [Route("register")] + public async Task HandleRegister(RegisterPayload payload) + { + // Check if IP banned + var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == HttpContext.Connection.RemoteIpAddress!.GetAddressBytes()); + if (ban != null) + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new RegisterResponse + { + success = false, + error = $"You are banned: {ban.Reason}" + }); + } + // Check captcha response + if (Program.Config.hCaptcha!.Enabled) + { + if (string.IsNullOrWhiteSpace(payload.captchaToken)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Missing hCaptcha token" + }); + } + var result = + await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString()); + if (!result.success) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Invalid captcha response" + }); + } + } + // Make sure username isn't taken + if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) || await _dbContext.Bots.AnyAsync(b => b.Username == payload.username)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That username is taken." + }); + } + // Check if E-Mail is in use + if (await _dbContext.Users.AnyAsync(u => u.Email == payload.email)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That E-Mail is already in use." + }); + } + // Validate username + if (!Utilities.ValidateUsername(payload.username)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." + }); + } + // Validate E-Mail + if (!new EmailAddressAttribute().IsValid(payload.email)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Malformed E-Mail address." + }); + } + if (Program.Config.Registration!.EmailDomainWhitelist && + !Program.Config.Registration.AllowedEmailDomains!.Contains(payload.email.Split("@")[1])) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That E-Mail domain is not allowed." + }); + } + // Validate password + if (!Utilities.ValidatePassword(payload.password)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." + }); + } + if (Program.BannedPasswords.Contains(payload.password)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "That password is commonly used and is not allowed." + }); + } + // Validate date of birth + if (!DateOnly.TryParseExact(payload.dateOfBirth, "yyyy-MM-dd", out var dob)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Invalid date of birth" + }); + } + + if (dob.AddYears(13) > DateOnly.FromDateTime(DateTime.Now)) + { + HttpContext.Response.StatusCode = 400; + await _dbContext.IpBans.AddAsync(new IpBan { + Ip = HttpContext.Connection.RemoteIpAddress!.GetAddressBytes(), + Reason = "You are not old enough to use CollabVM.", + BannedAt = DateTime.UtcNow + }); + await _dbContext.SaveChangesAsync(); + return Results.Json(new RegisterResponse + { + success = false, + error = "You are not old enough to use CollabVM." + }); + } + // theres no fucking chance + if (dob < new DateOnly(1954, 1, 1)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new RegisterResponse + { + success = false, + error = "Are you sure about that?" + }); + } + // Create the account + string? token = null; + var user = new User { + Username = payload.username, + Password = Argon2.Hash(payload.password), + Email = payload.email, + DateOfBirth = dob, + // If this is the first user, make them an admin + CvmRank = (uint) ((await _dbContext.Users.AnyAsync()) ? 1 : 2), + RegistrationIp = HttpContext.Connection.RemoteIpAddress!.GetAddressBytes(), + Created = DateTime.UtcNow, + }; + _dbContext.Users.Add(user); + + if (Program.Config.Registration.EmailVerificationRequired) + { + user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString(); + await _dbContext.SaveChangesAsync(); + await Program.Mailer!.SendVerificationCode(user.Username, user.Email, user.EmailVerificationCode); + } + else + { + user.EmailVerified = true; + await _dbContext.SaveChangesAsync(); + var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())])); + await HttpContext.SignInAsync(userPrincipal); + token = userPrincipal.FindFirstValue("token") + ?? throw new InvalidOperationException("Sign in handler did not add token"); + } + + return Results.Json(new RegisterResponse + { + success = true, + verificationRequired = !user.EmailVerified, + email = user.Email, + username = user.Username, + sessionToken = token + }); + } + + [HttpGet] + [Route("info")] + public IResult HandleInfo() + { + return Results.Json(new AuthServerInformation + { + // TODO: Implement registration closure + registrationOpen = true, + hcaptcha = + new() { + required = Program.Config.hCaptcha!.Enabled, + siteKey = Program.Config.hCaptcha.Enabled ? Program.Config.hCaptcha.SiteKey : null + } + }); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Controllers/DeveloperApiController.cs b/CollabVMAuthServer/HTTP/Controllers/DeveloperApiController.cs new file mode 100644 index 0000000..1dc1fcb --- /dev/null +++ b/CollabVMAuthServer/HTTP/Controllers/DeveloperApiController.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; +using Computernewb.CollabVMAuthServer.Database.Schema; +using Computernewb.CollabVMAuthServer.HTTP.Payloads; +using Computernewb.CollabVMAuthServer.HTTP.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Computernewb.CollabVMAuthServer.HTTP.Controllers; + +[Route("api/v1/bots")] +[ApiController] +[Authorize("Developer")] +public class DeveloperApiController : ControllerBase { + + private readonly CollabVMAuthDbContext _dbContext; + public DeveloperApiController(CollabVMAuthDbContext dbContext) { + this._dbContext = dbContext; + } + + [HttpPost] + [Route("list")] + public async Task HandleListBots(ListBotsPayload payload) + { + // owner can only be specified by admins and moderators + if (payload.owner != null && !(User.HasClaim("rank", "2") || User.HasClaim("rank", "3"))) + { + HttpContext.Response.StatusCode = 403; + return Results.Json(new ListBotsResponse + { + success = false, + error = "Insufficient permissions" + }); + } + // Get bots + // If the user is not an admin, they can only see their own bots + IQueryable result = _dbContext.Bots.Include(b => b.OwnerNavigation); + + if (payload.owner != null) { + result = result.Where(b => b.OwnerNavigation.Username == payload.owner); + } else if (!User.HasClaim("rank", "2") && !User.HasClaim("rank", "3")) { + result = result.Where(b => b.OwnerNavigation.Username == User.FindFirstValue("username")!); + } + + result = result.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage); + + var bots = await result.Select(bot => new ListBot + { + id = (int)bot.Id, + username = bot.Username, + rank = bot.CvmRank, + owner = bot.OwnerNavigation.Username, + created = bot.Created.ToString("yyyy-MM-dd HH:mm:ss") + + }).ToArrayAsync(); + + return Results.Json(new ListBotsResponse + { + success = true, + totalPageCount = (int)Math.Ceiling(await _dbContext.Bots.CountAsync() / (double)payload.resultsPerPage), + bots = bots + }); + } + + [HttpPost] + [Route("create")] + public async Task HandleCreateBot(CreateBotPayload payload) + { + // Check bot username + if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) || + await _dbContext.Bots.AnyAsync(b => b.Username == payload.username)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new CreateBotResponse + { + success = false, + error = "That username is taken." + }); + } + + if (!Utilities.ValidateUsername(payload.username)) + { + HttpContext.Response.StatusCode = 400; + return Results.Json(new CreateBotResponse + { + success = false, + error = + "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." + }); + } + // Generate token + string token = Utilities.RandomString(64); + // Create bot + var bot = new Bot { + Username = payload.username, + Token = token, + CvmRank = 1, + Owner = uint.Parse(HttpContext.User.FindFirstValue("id")!), + Created = DateTime.UtcNow + }; + _dbContext.Bots.Add(bot); + await _dbContext.SaveChangesAsync(); + + return Results.Json(new CreateBotResponse + { + success = true, + token = token + }); + } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/DeveloperRoutes.cs b/CollabVMAuthServer/HTTP/DeveloperRoutes.cs deleted file mode 100644 index 4e56e1e..0000000 --- a/CollabVMAuthServer/HTTP/DeveloperRoutes.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Computernewb.CollabVMAuthServer.HTTP.Payloads; -using Computernewb.CollabVMAuthServer.HTTP.Responses; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Computernewb.CollabVMAuthServer.HTTP; - -public static class DeveloperRoutes -{ - public static void RegisterRoutes(IEndpointRouteBuilder app) - { - app.MapPost("/api/v1/bots/create", (Delegate)HandleCreateBot); - app.MapPost("/api/v1/bots/list", (Delegate)HandleListBots); - } - - private static async Task HandleListBots(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new ListBotsResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - ListBotsPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new ListBotsResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || payload.resultsPerPage <= 0 || - payload.page <= 0) - { - context.Response.StatusCode = 400; - return Results.Json(new ListBotsResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var session = await Program.Database.GetSession(payload.token); - if (session == null || Utilities.IsSessionExpired(session)) - { - context.Response.StatusCode = 400; - return Results.Json(new ListBotsResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check developer status - var user = await Program.Database.GetUser(session.Username) ?? - throw new Exception("Unable to get user from session"); - if (!user.Developer && user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new CreateBotResponse - { - success = false, - error = "You must be an approved developer to create and manage bots." - }, Utilities.JsonSerializerOptions); - } - // owner can only be specified by admins and moderators - if (payload.owner != null && user.Rank != Rank.Admin && user.Rank != Rank.Moderator) - { - context.Response.StatusCode = 403; - return Results.Json(new ListBotsResponse - { - success = false, - error = "Insufficient permissions" - }, Utilities.JsonSerializerOptions); - } - // Get bots - // If the user is not an admin, they can only see their own bots - var bots = (await Program.Database.ListBots(payload.owner ?? ((user.Rank == Rank.Admin || user.Rank == Rank.Moderator) ? null : user.Username))).Select(bot => new ListBot - { - id = (int)bot.Id, - username = bot.Username, - rank = (int)bot.Rank, - owner = bot.Owner, - created = bot.Created.ToString("yyyy-MM-dd HH:mm:ss") - - }); - var page = bots.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage).ToArray(); - return Results.Json(new ListBotsResponse - { - success = true, - totalPageCount = (int)Math.Ceiling(bots.Count() / (double)payload.resultsPerPage), - bots = page - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleCreateBot(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - CreateBotPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check token - var session = await Program.Database.GetSession(payload.token); - if (session == null || Utilities.IsSessionExpired(session)) - { - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - // Check developer status - var user = await Program.Database.GetUser(session.Username) ?? - throw new Exception("Unable to get user from session"); - if (!user.Developer) - { - context.Response.StatusCode = 403; - return Results.Json(new CreateBotResponse - { - success = false, - error = "You must be an approved developer to create and manage bots." - }, Utilities.JsonSerializerOptions); - } - // Check bot username - if (await Program.Database.GetBot(payload.username) != null || - await Program.Database.GetUser(payload.username) != null) - { - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = "That username is taken." - }, Utilities.JsonSerializerOptions); - } - - if (!Utilities.ValidateUsername(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new CreateBotResponse - { - success = false, - error = - "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." - }, Utilities.JsonSerializerOptions); - } - // Generate token - string token = Utilities.RandomString(64); - // Create bot - await Program.Database.CreateBot(payload.username, token, user.Username); - return Results.Json(new CreateBotResponse - { - success = true, - token = token - }, Utilities.JsonSerializerOptions); - } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/AdminUpdateBotPayload.cs b/CollabVMAuthServer/HTTP/Payloads/AdminUpdateBotPayload.cs index 497b353..dac19b4 100644 --- a/CollabVMAuthServer/HTTP/Payloads/AdminUpdateBotPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/AdminUpdateBotPayload.cs @@ -2,7 +2,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class AdminUpdateBotPayload { - public string token { get; set; } - public string username { get; set; } - public int? rank { get; set; } + public required string username { get; set; } + public uint? rank { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/AdminUpdateUserPayload.cs b/CollabVMAuthServer/HTTP/Payloads/AdminUpdateUserPayload.cs index 1b6cfc1..aa394bc 100644 --- a/CollabVMAuthServer/HTTP/Payloads/AdminUpdateUserPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/AdminUpdateUserPayload.cs @@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class AdminUpdateUserPayload { - public string token { get; set; } - public string username { get; set; } - public int? rank { get; set; } + public required string username { get; set; } + public uint? rank { get; set; } public bool? developer { get; set; } = null; } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/AdminUsersPayload.cs b/CollabVMAuthServer/HTTP/Payloads/AdminUsersPayload.cs index 6dd7e76..4366c6a 100644 --- a/CollabVMAuthServer/HTTP/Payloads/AdminUsersPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/AdminUsersPayload.cs @@ -2,7 +2,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class AdminUsersPayload { - public string token { get; set; } public int resultsPerPage { get; set; } public int page { get; set; } public string? filterUsername { get; set; } diff --git a/CollabVMAuthServer/HTTP/Payloads/BanUserPayload.cs b/CollabVMAuthServer/HTTP/Payloads/BanUserPayload.cs index cd7b729..b8c5617 100644 --- a/CollabVMAuthServer/HTTP/Payloads/BanUserPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/BanUserPayload.cs @@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class BanUserPayload { - public string token { get; set; } - public string username { get; set; } + public required string username { get; set; } public bool banned { get; set; } - public string reason { get; set; } + public string? reason { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/CreateBotPayload.cs b/CollabVMAuthServer/HTTP/Payloads/CreateBotPayload.cs index 85b75f7..0fa7967 100644 --- a/CollabVMAuthServer/HTTP/Payloads/CreateBotPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/CreateBotPayload.cs @@ -2,6 +2,5 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class CreateBotPayload { - public string token { get; set; } - public string username { get; set; } + public required string username { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/IPBanPayload.cs b/CollabVMAuthServer/HTTP/Payloads/IPBanPayload.cs index 872e76f..b7b3b11 100644 --- a/CollabVMAuthServer/HTTP/Payloads/IPBanPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/IPBanPayload.cs @@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class IPBanPayload { - public string session { get; set; } - public string ip { get; set; } + public required string ip { get; set; } public bool banned { get; set; } - public string reason { get; set; } + public required string reason { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/JoinPayload.cs b/CollabVMAuthServer/HTTP/Payloads/JoinPayload.cs index da6f10e..b18c874 100644 --- a/CollabVMAuthServer/HTTP/Payloads/JoinPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/JoinPayload.cs @@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class JoinPayload { - public string secretKey { get; set; } - public string sessionToken { get; set; } - public string ip { get; set; } + public required string secretKey { get; set; } + public required string sessionToken { get; set; } + public required string ip { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/ListBotsPayload.cs b/CollabVMAuthServer/HTTP/Payloads/ListBotsPayload.cs index 3d0d1aa..e3fbfd3 100644 --- a/CollabVMAuthServer/HTTP/Payloads/ListBotsPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/ListBotsPayload.cs @@ -2,7 +2,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class ListBotsPayload { - public string token { get; set; } public int resultsPerPage { get; set; } public int page { get; set; } public string? owner { get; set; } diff --git a/CollabVMAuthServer/HTTP/Payloads/LoginPayload.cs b/CollabVMAuthServer/HTTP/Payloads/LoginPayload.cs index 7b66f02..b07ea8c 100644 --- a/CollabVMAuthServer/HTTP/Payloads/LoginPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/LoginPayload.cs @@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class LoginPayload { - public string username { get; set; } - public string password { get; set; } + public required string username { get; set; } + public required string password { get; set; } public string? captchaToken { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/LogoutPayload.cs b/CollabVMAuthServer/HTTP/Payloads/LogoutPayload.cs deleted file mode 100644 index eafa7e0..0000000 --- a/CollabVMAuthServer/HTTP/Payloads/LogoutPayload.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; - -public class LogoutPayload -{ - public string token { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/RegisterPayload.cs b/CollabVMAuthServer/HTTP/Payloads/RegisterPayload.cs index 6666b03..b293973 100644 --- a/CollabVMAuthServer/HTTP/Payloads/RegisterPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/RegisterPayload.cs @@ -2,9 +2,9 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class RegisterPayload { - public string username { get; set; } - public string password { get; set; } - public string email { get; set; } + public required string username { get; set; } + public required string password { get; set; } + public required string email { get; set; } public string? captchaToken { get; set; } - public string dateOfBirth { get; set; } + public required string dateOfBirth { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/RequestBodyAuthenticationPayload.cs b/CollabVMAuthServer/HTTP/Payloads/RequestBodyAuthenticationPayload.cs new file mode 100644 index 0000000..a9b3c4b --- /dev/null +++ b/CollabVMAuthServer/HTTP/Payloads/RequestBodyAuthenticationPayload.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; + +public class RequestBodyAuthenticationPayload { + [JsonPropertyName("session")] + public string? Session { get; set; } + [JsonPropertyName("token")] + public string? Token { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs b/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs index 221559b..9131b57 100644 --- a/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs @@ -2,8 +2,8 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class ResetPasswordPayload { - public string username { get; set; } - public string email { get; set; } - public string code { get; set; } - public string newPassword { get; set; } + public required string username { get; set; } + public required string email { get; set; } + public required string code { get; set; } + public required string newPassword { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs b/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs index d1dcf42..f7cb2bc 100644 --- a/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs @@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class SendResetEmailPayload { - public string email { get; set; } - public string username { get; set; } + public required string email { get; set; } + public required string username { get; set; } public string? captchaToken { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/SessionPayload.cs b/CollabVMAuthServer/HTTP/Payloads/SessionPayload.cs deleted file mode 100644 index ce6c177..0000000 --- a/CollabVMAuthServer/HTTP/Payloads/SessionPayload.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; - -public class SessionPayload -{ - public string token { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/UpdatePayload.cs b/CollabVMAuthServer/HTTP/Payloads/UpdatePayload.cs index cd537a9..245682a 100644 --- a/CollabVMAuthServer/HTTP/Payloads/UpdatePayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/UpdatePayload.cs @@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class UpdatePayload { - public string token { get; set; } - public string currentPassword { get; set; } + public required string currentPassword { get; set; } public string? newPassword { get; set; } public string? username { get; set; } diff --git a/CollabVMAuthServer/HTTP/Payloads/VerifyPayload.cs b/CollabVMAuthServer/HTTP/Payloads/VerifyPayload.cs index 2c4c7d8..3051f78 100644 --- a/CollabVMAuthServer/HTTP/Payloads/VerifyPayload.cs +++ b/CollabVMAuthServer/HTTP/Payloads/VerifyPayload.cs @@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; public class VerifyPayload { - public string username { get; set; } - public string password { get; set; } - public string code { get; set; } + public required string username { get; set; } + public required string password { get; set; } + public required string code { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/AdminUpdateBotResponse.cs b/CollabVMAuthServer/HTTP/Responses/AdminUpdateBotResponse.cs deleted file mode 100644 index 6713cd1..0000000 --- a/CollabVMAuthServer/HTTP/Responses/AdminUpdateBotResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class AdminUpdateBotResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/AdminUpdateUserResponse.cs b/CollabVMAuthServer/HTTP/Responses/AdminUpdateUserResponse.cs deleted file mode 100644 index 3c7a89c..0000000 --- a/CollabVMAuthServer/HTTP/Responses/AdminUpdateUserResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class AdminUpdateUserResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/AdminUsersResponse.cs b/CollabVMAuthServer/HTTP/Responses/AdminUsersResponse.cs index 598e4ff..24c2472 100644 --- a/CollabVMAuthServer/HTTP/Responses/AdminUsersResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/AdminUsersResponse.cs @@ -1,9 +1,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class AdminUsersResponse +public class AdminUsersResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public int? totalPageCount { get; set; } = null; public AdminUser[]? users { get; set; } } @@ -11,13 +9,13 @@ public class AdminUsersResponse public class AdminUser { public uint id { get; set; } - public string username { get; set; } - public string email { get; set; } - public int rank { get; set; } + public required string username { get; set; } + public required string email { get; set; } + public required uint rank { get; set; } public bool banned { get; set; } - public string banReason { get; set; } - public string dateOfBirth { get; set; } - public string dateJoined { get; set; } - public string registrationIp { get; set; } + public required string banReason { get; set; } + public required string dateOfBirth { get; set; } + public required string dateJoined { get; set; } + public required string registrationIp { get; set; } public bool developer { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/IPBanResponse.cs b/CollabVMAuthServer/HTTP/Responses/ApiResponse.cs similarity index 82% rename from CollabVMAuthServer/HTTP/Responses/IPBanResponse.cs rename to CollabVMAuthServer/HTTP/Responses/ApiResponse.cs index 8f05ef5..b366fa1 100644 --- a/CollabVMAuthServer/HTTP/Responses/IPBanResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/ApiResponse.cs @@ -1,7 +1,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class IPBanResponse -{ +public class ApiResponse { public bool success { get; set; } public string? error { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/AuthServerInformation.cs b/CollabVMAuthServer/HTTP/Responses/AuthServerInformation.cs index 3f87823..a5bb25a 100644 --- a/CollabVMAuthServer/HTTP/Responses/AuthServerInformation.cs +++ b/CollabVMAuthServer/HTTP/Responses/AuthServerInformation.cs @@ -3,7 +3,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; public class AuthServerInformation { public bool registrationOpen { get; set; } - public AuthServerInformationCaptcha hcaptcha { get; set; } + public required AuthServerInformationCaptcha hcaptcha { get; set; } } public class AuthServerInformationCaptcha diff --git a/CollabVMAuthServer/HTTP/Responses/BanUserResponse.cs b/CollabVMAuthServer/HTTP/Responses/BanUserResponse.cs deleted file mode 100644 index 239d05e..0000000 --- a/CollabVMAuthServer/HTTP/Responses/BanUserResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class BanUserResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/CreateBotResponse.cs b/CollabVMAuthServer/HTTP/Responses/CreateBotResponse.cs index fe27dc3..93eb511 100644 --- a/CollabVMAuthServer/HTTP/Responses/CreateBotResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/CreateBotResponse.cs @@ -1,8 +1,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class CreateBotResponse +public class CreateBotResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public string? token { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/JoinResponse.cs b/CollabVMAuthServer/HTTP/Responses/JoinResponse.cs index 5b37ef0..623fd92 100644 --- a/CollabVMAuthServer/HTTP/Responses/JoinResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/JoinResponse.cs @@ -1,12 +1,10 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class JoinResponse +public class JoinResponse : ApiResponse { - public bool success { get; set; } public bool clientSuccess { get; set; } = false; public bool? banned { get; set; } = null; public string? banReason { get; set; } - public string? error { get; set; } public string? username { get; set; } - public Rank? rank { get; set; } + public uint? rank { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/ListBot.cs b/CollabVMAuthServer/HTTP/Responses/ListBot.cs index 1a7e049..73e506d 100644 --- a/CollabVMAuthServer/HTTP/Responses/ListBot.cs +++ b/CollabVMAuthServer/HTTP/Responses/ListBot.cs @@ -3,8 +3,8 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; public class ListBot { public int id { get; set; } - public string username { get; set; } - public int rank { get; set; } - public string owner { get; set; } - public string created { get; set; } + public required string username { get; set; } + public uint rank { get; set; } + public required string owner { get; set; } + public required string created { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/ListBotsResponse.cs b/CollabVMAuthServer/HTTP/Responses/ListBotsResponse.cs index 49aeb35..75f8506 100644 --- a/CollabVMAuthServer/HTTP/Responses/ListBotsResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/ListBotsResponse.cs @@ -1,9 +1,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class ListBotsResponse +public class ListBotsResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public int? totalPageCount { get; set; } = null; public ListBot[]? bots { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/LoginResponse.cs b/CollabVMAuthServer/HTTP/Responses/LoginResponse.cs index ee1a602..7d8e18f 100644 --- a/CollabVMAuthServer/HTTP/Responses/LoginResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/LoginResponse.cs @@ -1,13 +1,11 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class LoginResponse +public class LoginResponse : ApiResponse { - public bool success { get; set; } public string? token { get; set; } - public string? error { get; set; } public bool? verificationRequired { get; set; } public string? email { get; set; } public string? username { get; set; } - public int rank { get; set; } + public uint rank { get; set; } public bool? developer { get; set; } = null; } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/LogoutResponse.cs b/CollabVMAuthServer/HTTP/Responses/LogoutResponse.cs deleted file mode 100644 index cd6762d..0000000 --- a/CollabVMAuthServer/HTTP/Responses/LogoutResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class LogoutResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/RegisterResponse.cs b/CollabVMAuthServer/HTTP/Responses/RegisterResponse.cs index c5b58ca..303c954 100644 --- a/CollabVMAuthServer/HTTP/Responses/RegisterResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/RegisterResponse.cs @@ -1,9 +1,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class RegisterResponse +public class RegisterResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public bool? verificationRequired { get; set; } = null; public string? username { get; set; } public string? email { get; set; } diff --git a/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs b/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs deleted file mode 100644 index 5f119f6..0000000 --- a/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class ResetPasswordResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/SendResetEmailResponse.cs b/CollabVMAuthServer/HTTP/Responses/SendResetEmailResponse.cs deleted file mode 100644 index b959585..0000000 --- a/CollabVMAuthServer/HTTP/Responses/SendResetEmailResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer.HTTP.Responses; - -public class SendResetEmailResponse -{ - public bool success { get; set; } - public string? error { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/SessionResponse.cs b/CollabVMAuthServer/HTTP/Responses/SessionResponse.cs index 03ede7e..ed7510f 100644 --- a/CollabVMAuthServer/HTTP/Responses/SessionResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/SessionResponse.cs @@ -1,12 +1,10 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class SessionResponse +public class SessionResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public bool banned { get; set; } = false; public string? username { get; set; } public string? email { get; set; } - public int rank { get; set; } + public uint rank { get; set; } public bool? developer { get; set; } = null; } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/UpdateResponse.cs b/CollabVMAuthServer/HTTP/Responses/UpdateResponse.cs index 4689ef5..13957dd 100644 --- a/CollabVMAuthServer/HTTP/Responses/UpdateResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/UpdateResponse.cs @@ -1,9 +1,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class UpdateResponse +public class UpdateResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public bool? verificationRequired { get; set; } = null; public bool? sessionExpired { get; set; } = null; } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/VerifyResponse.cs b/CollabVMAuthServer/HTTP/Responses/VerifyResponse.cs index fe6c458..fcea4af 100644 --- a/CollabVMAuthServer/HTTP/Responses/VerifyResponse.cs +++ b/CollabVMAuthServer/HTTP/Responses/VerifyResponse.cs @@ -1,8 +1,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses; -public class VerifyResponse +public class VerifyResponse : ApiResponse { - public bool success { get; set; } - public string? error { get; set; } public string? sessionToken { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Routes.cs b/CollabVMAuthServer/HTTP/Routes.cs deleted file mode 100644 index 36171e3..0000000 --- a/CollabVMAuthServer/HTTP/Routes.cs +++ /dev/null @@ -1,1085 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Net; -using System.Text.Json; -using System.Threading.Tasks; -using Computernewb.CollabVMAuthServer.HTTP.Payloads; -using Computernewb.CollabVMAuthServer.HTTP.Responses; -using Isopoh.Cryptography.Argon2; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace Computernewb.CollabVMAuthServer.HTTP; - -public static class Routes -{ - public static void RegisterRoutes(WebApplication app) - { - app.MapGet("/api/v1/info", HandleInfo); - app.MapPost("/api/v1/register", (Delegate) HandleRegister); - app.MapPost("/api/v1/verify", (Delegate) HandleVerify); - app.MapPost("/api/v1/login", (Delegate) HandleLogin); - app.MapPost("/api/v1/session", (Delegate) HandleSession); - app.MapPost("/api/v1/join", (Delegate)HandleJoin); - app.MapPost("/api/v1/logout", (Delegate)HandleLogout); - app.MapPost("/api/v1/update", (Delegate)HandleUpdate); - app.MapPost("/api/v1/sendreset", (Delegate)HandleSendReset); - app.MapPost("/api/v1/reset", (Delegate)HandleReset); - } - - private static async Task HandleSendReset(HttpContext context) - { - if (!Program.Config.SMTP.Enabled) - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Password reset is not supported by this server. Please contact an administrator." - }, Utilities.JsonSerializerOptions); - } - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - - SendResetEmailPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.email) || string.IsNullOrWhiteSpace(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - var ip = Utilities.GetIP(context); - if (ip == null) - { - context.Response.StatusCode = 403; - return Results.Empty; - } - // Check captcha response - if (Program.Config.hCaptcha.Enabled) - { - if (string.IsNullOrWhiteSpace(payload.captchaToken)) - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Missing hCaptcha token" - }, Utilities.JsonSerializerOptions); - } - var result = - await Program.hCaptcha!.Verify(payload.captchaToken, ip.ToString()); - if (!result.success) - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Invalid captcha response" - }, Utilities.JsonSerializerOptions); - } - } - // Check username and E-Mail - var user = await Program.Database.GetUser(payload.username); - if (user == null || user.Email != payload.email) - { - context.Response.StatusCode = 400; - return Results.Json(new SendResetEmailResponse - { - success = false, - error = "Invalid username or E-Mail" - }, Utilities.JsonSerializerOptions); - } - // Generate reset code - var code = Program.Random.Next(10000000, 99999999).ToString(); - await Program.Database.SetPasswordResetCode(payload.username, code); - await Program.Mailer.SendPasswordResetEmail(payload.username, payload.email, code); - return Results.Json(new SendResetEmailResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleReset(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - ResetPasswordPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.username) || - string.IsNullOrWhiteSpace(payload.email) || string.IsNullOrWhiteSpace(payload.code) || - string.IsNullOrWhiteSpace(payload.newPassword)) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Is mailer enabled? - if (Program.Mailer == null) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Password reset is disabled" - }, Utilities.JsonSerializerOptions); - } - // Check username and E-Mail - var user = await Program.Database.GetUser(payload.username); - if (user == null || user.Email != payload.email) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Invalid username or E-Mail" - }, Utilities.JsonSerializerOptions); - } - // Check if code is correct - if (user.PasswordResetCode != payload.code) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Invalid reset code" - }, Utilities.JsonSerializerOptions); - } - // Validate new password - if (!Utilities.ValidatePassword(payload.newPassword)) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." - }, Utilities.JsonSerializerOptions); - } - if (Program.BannedPasswords.Contains(payload.newPassword)) - { - context.Response.StatusCode = 400; - return Results.Json(new ResetPasswordResponse - { - success = false, - error = "That password is commonly used and is not allowed." - }, Utilities.JsonSerializerOptions); - } - // Reset password - await Program.Database.UpdateUser(payload.username, newPassword: payload.newPassword); - await Program.Database.SetPasswordResetCode(payload.username, null); - await Program.Database.RevokeAllSessions(payload.username); - return Results.Json(new ResetPasswordResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleUpdate(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new UpdateResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - UpdatePayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new UpdateResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token) || - string.IsNullOrWhiteSpace(payload.currentPassword) || (string.IsNullOrWhiteSpace(payload.newPassword) && string.IsNullOrWhiteSpace(payload.username) && string.IsNullOrWhiteSpace(payload.email))) - { - context.Response.StatusCode = 400; - return Results.Json(new UpdateResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check if session is valid - var session = await Program.Database.GetSession(payload.token); - if (session == null || DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts.SessionExpiryDays)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - // Check password - var user = await Program.Database.GetUser(session.Username) - ?? throw new Exception("User not found in database (something is very wrong)"); - if (!Argon2.Verify(user.Password, payload.currentPassword)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "Invalid password", - }, Utilities.JsonSerializerOptions); - } - // Validate new username - if (payload.username != null) - { - if (!Utilities.ValidateUsername(payload.username)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." - }, Utilities.JsonSerializerOptions); - } - // Make sure username isn't taken - if (await Program.Database.GetUser(payload.username) != null || await Program.Database.GetBot(payload.username) != null) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That username is taken." - }, Utilities.JsonSerializerOptions); - } - } - // Validate new E-Mail - if (payload.email != null) - { - if (!new EmailAddressAttribute().IsValid(payload.email)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "Malformed E-Mail address." - }, Utilities.JsonSerializerOptions); - } - if (Program.Config.Registration.EmailDomainWhitelist && !Program.Config.Registration.AllowedEmailDomains.Contains(payload.email.Split("@")[1])) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "That E-Mail domain is not allowed." - }, Utilities.JsonSerializerOptions); - } - // Check if E-Mail is in use - var _user = await Program.Database.GetUser(email: payload.email); - if (_user != null) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That E-Mail is already in use." - }, Utilities.JsonSerializerOptions); - } - } - // Validate new password - if (payload.newPassword != null) - { - if (!Utilities.ValidatePassword(payload.newPassword)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." - }, Utilities.JsonSerializerOptions); - } - if (Program.BannedPasswords.Contains(payload.newPassword)) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "That password is commonly used and is not allowed." - }, Utilities.JsonSerializerOptions); - } - } - // Check for duplicate changes - if (payload.username == user.Username || payload.email == user.Email || - (payload.newPassword != null && Argon2.Verify(user.Password, payload.newPassword))) - { - return Results.Json(new UpdateResponse - { - success = false, - error = "No changes were made." - }); - } - // Perform update - await Program.Database.UpdateUser(user.Username, payload.username, payload.newPassword, payload.email); - // Revoke all sessions - await Program.Database.RevokeAllSessions(user.Username); - // Unverify the account if the E-Mail was changed - if (payload.email != null) - { - await Program.Database.SetUserVerified(user.Username, false); - var code = Program.Random.Next(10000000, 99999999).ToString(); - await Program.Database.SetVerificationCode(user.Username, code); - await Program.Mailer.SendVerificationCode(user.Username, payload.email, code); - } - return Results.Json(new UpdateResponse - { - success = true, - verificationRequired = payload.email != null, - sessionExpired = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleLogout(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new LogoutResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - LogoutPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new LogoutResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token)) - { - context.Response.StatusCode = 400; - return Results.Json(new LogoutResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check if session is valid - var session = await Program.Database.GetSession(payload.token); - if (session == null) - { - return Results.Json(new LogoutResponse - { - success = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - // Revoke session - await Program.Database.RevokeSession(payload.token); - return Results.Json(new LogoutResponse - { - success = true - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleSession(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new SessionResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - SessionPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new SessionResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.token)) - { - context.Response.StatusCode = 400; - return Results.Json(new SessionResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check if session is valid - var session = await Program.Database.GetSession(payload.token); - if (session == null) - { - return Results.Json(new SessionResponse - { - success = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - // Check if session is expired - if (Utilities.IsSessionExpired(session)) - { - return Results.Json(new SessionResponse - { - success = false, - error = "Expired session", - }, Utilities.JsonSerializerOptions); - } - var user = await Program.Database.GetUser(session.Username) - ?? throw new Exception("User not found in database (something is very wrong)"); - return Results.Json(new SessionResponse - { - success = true, - banned = user.Banned, - username = user.Username, - email = user.Email, - rank = (int)user.Rank, - developer = user.Developer - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleJoin(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new JoinResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - JoinPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new JoinResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.secretKey) || string.IsNullOrWhiteSpace(payload.sessionToken) || string.IsNullOrWhiteSpace(payload.ip)) - { - context.Response.StatusCode = 400; - return Results.Json(new JoinResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check secret key - if (payload.secretKey != Program.Config.CollabVM.SecretKey) - { - context.Response.StatusCode = 401; - return Results.Json(new JoinResponse - { - success = false, - error = "Invalid secret key" - }, Utilities.JsonSerializerOptions); - } - // Check if IP banned - if (!IPAddress.TryParse(payload.ip, out var ip)) - { - context.Response.StatusCode = 400; - return Results.Json(new JoinResponse - { - success = false, - error = "Malformed IP address" - }); - } - var ban = await Program.Database.CheckIPBan(ip); - if (ban != null) - { - context.Response.StatusCode = 200; - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - error = "Banned", - banned = true, - banReason = ban.Reason - }, Utilities.JsonSerializerOptions); - } - // Check if session is valid - if (payload.sessionToken.Length == 32) - { - // User - var session = await Program.Database.GetSession(payload.sessionToken); - if (session == null) - { - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - // Check if session is expired - if (DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts.SessionExpiryDays)) - { - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - // Check if banned - var user = await Program.Database.GetUser(session.Username) - ?? throw new Exception("User not found in database (something is very wrong)"); - if (user.Banned) - { - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - banned = true, - error = "Banned", - banReason = user.BanReason - }, Utilities.JsonSerializerOptions); - } - // Update session - await Program.Database.UpdateSessionLastUsed(session.Token, IPAddress.Parse(payload.ip)); - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = true, - username = session.Username, - rank = user.Rank - }, Utilities.JsonSerializerOptions); - } else if (payload.sessionToken.Length == 64) - { - // Bot - var bot = await Program.Database.GetBot(token: payload.sessionToken); - if (bot == null) - { - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - error = "Invalid session", - }, Utilities.JsonSerializerOptions); - } - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = true, - username = bot.Username, - rank = bot.Rank - }, Utilities.JsonSerializerOptions); - } - else - { - context.Response.StatusCode = 400; - return Results.Json(new JoinResponse - { - success = true, - clientSuccess = false, - error = "Invalid session" - }, Utilities.JsonSerializerOptions); - } - } - - private static async Task HandleLogin(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new LoginResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - LoginPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new LoginResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.username) || string.IsNullOrWhiteSpace(payload.password)) - { - context.Response.StatusCode = 400; - return Results.Json(new LoginResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - var ip = Utilities.GetIP(context); - if (ip == null) - { - context.Response.StatusCode = 403; - return Results.Empty; - } - // Check captcha response - if (Program.Config.hCaptcha.Enabled) - { - if (string.IsNullOrWhiteSpace(payload.captchaToken)) - { - context.Response.StatusCode = 400; - return Results.Json(new LoginResponse - { - success = false, - error = "Missing hCaptcha token" - }, Utilities.JsonSerializerOptions); - } - var result = - await Program.hCaptcha!.Verify(payload.captchaToken, ip.ToString()); - if (!result.success) - { - context.Response.StatusCode = 400; - return Results.Json(new LoginResponse - { - success = false, - error = "Invalid captcha response" - }, Utilities.JsonSerializerOptions); - } - } - // Validate username and password - var user = await Program.Database.GetUser(payload.username); - if (user == null || !Argon2.Verify(user.Password, payload.password)) - { - context.Response.StatusCode = 403; - return Results.Json(new LoginResponse - { - success = false, - error = "Invalid username or password" - }, Utilities.JsonSerializerOptions); - } - // Check if IP banned - var ban = await Program.Database.CheckIPBan(ip); - if (ban != null) - { - context.Response.StatusCode = 403; - return Results.Json(new LoginResponse - { - success = false, - error = $"You are banned: {ban.Reason}" - }, Utilities.JsonSerializerOptions); - } - // Check if account is verified - if (!user.EmailVerified) - { - return Results.Json(new LoginResponse - { - success = true, - verificationRequired = true, - email = user.Email, - username = user.Username, - rank = (int)user.Rank, - developer = user.Developer - }); - } - // Check max sessions - var sessions = await Program.Database.GetSessions(user.Username); - if (sessions.Length >= Program.Config.Accounts.MaxSessions) - { - var oldest = sessions.OrderBy(s => s.LastUsed).First(); - await Program.Database.RevokeSession(oldest.Token); - } - // Generate token - var token = Utilities.RandomString(32); - await Program.Database.CreateSession(user.Username, token, ip); - return Results.Json(new LoginResponse - { - success = true, - token = token, - username = user.Username, - email = user.Email, - rank = (int)user.Rank, - developer = user.Developer - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleVerify(HttpContext context) - { - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new VerifyResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - - VerifyPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new VerifyResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.username) || - string.IsNullOrWhiteSpace(payload.password) || string.IsNullOrWhiteSpace(payload.password)) - { - context.Response.StatusCode = 400; - return Results.Json(new VerifyResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Validate username and password - var user = await Program.Database.GetUser(payload.username); - if (user == null || !Argon2.Verify(user.Password, payload.password)) - { - context.Response.StatusCode = 403; - return Results.Json(new VerifyResponse - { - success = false, - error = "Invalid username or password" - }, Utilities.JsonSerializerOptions); - } - // Check if account is verified - if (user.EmailVerified) - { - context.Response.StatusCode = 400; - return Results.Json(new VerifyResponse - { - success = false, - error = "Account is already verified" - }, Utilities.JsonSerializerOptions); - } - // Check if code is correct - if (user.EmailVerificationCode != payload.code) - { - context.Response.StatusCode = 400; - return Results.Json(new VerifyResponse - { - success = false, - error = "Invalid verification code" - }, Utilities.JsonSerializerOptions); - } - // Verify the account - await Program.Database.SetUserVerified(payload.username, true); - // Create a session - var token = Utilities.RandomString(32); - var ip = Utilities.GetIP(context); - if (ip == null) - { - context.Response.StatusCode = 403; - return Results.Empty; - } - await Program.Database.CreateSession(user.Username, token, ip); - return Results.Json(new VerifyResponse - { - success = true, - sessionToken = token, - }, Utilities.JsonSerializerOptions); - } - - private static async Task HandleRegister(HttpContext context) - { - var ip = Utilities.GetIP(context); - if (ip == null) - { - context.Response.StatusCode = 403; - return Results.Empty; - } - // Check payload - if (context.Request.ContentType != "application/json") - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - RegisterPayload? payload; - try - { - payload = await context.Request.ReadFromJsonAsync(); - } - catch (JsonException ex) - { - Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}"); - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - if (payload == null || string.IsNullOrWhiteSpace(payload.username) || string.IsNullOrWhiteSpace(payload.password) || string.IsNullOrWhiteSpace(payload.email) || string.IsNullOrWhiteSpace(payload.dateOfBirth)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Invalid request body" - }, Utilities.JsonSerializerOptions); - } - // Check if IP banned - var ban = await Program.Database.CheckIPBan(ip); - if (ban != null) - { - context.Response.StatusCode = 403; - return Results.Json(new RegisterResponse - { - success = false, - error = $"You are banned: {ban.Reason}" - }, Utilities.JsonSerializerOptions); - } - // Check captcha response - if (Program.Config.hCaptcha.Enabled) - { - if (string.IsNullOrWhiteSpace(payload.captchaToken)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Missing hCaptcha token" - }, Utilities.JsonSerializerOptions); - } - var result = - await Program.hCaptcha!.Verify(payload.captchaToken, ip.ToString()); - if (!result.success) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Invalid captcha response" - }, Utilities.JsonSerializerOptions); - } - } - // Make sure username isn't taken - var user = await Program.Database.GetUser(payload.username); - if (user != null || await Program.Database.GetBot(payload.username) != null) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That username is taken." - }, Utilities.JsonSerializerOptions); - } - // Check if E-Mail is in use - user = await Program.Database.GetUser(email: payload.email); - if (user != null) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That E-Mail is already in use." - }, Utilities.JsonSerializerOptions); - } - // Validate username - if (!Utilities.ValidateUsername(payload.username)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters." - }, Utilities.JsonSerializerOptions); - } - // Validate E-Mail - if (!new EmailAddressAttribute().IsValid(payload.email)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Malformed E-Mail address." - }, Utilities.JsonSerializerOptions); - } - if (Program.Config.Registration.EmailDomainWhitelist && - !Program.Config.Registration.AllowedEmailDomains.Contains(payload.email.Split("@")[1])) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That E-Mail domain is not allowed." - }, Utilities.JsonSerializerOptions); - } - // Validate password - if (!Utilities.ValidatePassword(payload.password)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol." - }, Utilities.JsonSerializerOptions); - } - if (Program.BannedPasswords.Contains(payload.password)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "That password is commonly used and is not allowed." - }, Utilities.JsonSerializerOptions); - } - // Validate date of birth - if (!DateOnly.TryParseExact(payload.dateOfBirth, "yyyy-MM-dd", out var dob)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Invalid date of birth" - }, Utilities.JsonSerializerOptions); - } - - if (dob.AddYears(13) > DateOnly.FromDateTime(DateTime.Now)) - { - context.Response.StatusCode = 400; - await Program.Database.BanIP(ip, "You are not old enough to use CollabVM."); - return Results.Json(new RegisterResponse - { - success = false, - error = "You are not old enough to use CollabVM." - }, Utilities.JsonSerializerOptions); - } - // theres no fucking chance - if (dob < new DateOnly(1954, 1, 1)) - { - context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse - { - success = false, - error = "Are you sure about that?" - }); - } - // Create the account - string? token = null; - if (Program.Config.Registration.EmailVerificationRequired) - { - var code = Program.Random.Next(10000000, 99999999).ToString(); - await Program.Database.RegisterAccount(payload.username, payload.email, dob, payload.password, false, ip,code); - await Program.Mailer.SendVerificationCode(payload.username, payload.email, code); - } - else - { - await Program.Database.RegisterAccount(payload.username, payload.email, dob, payload.password, true, ip, null); - token = Utilities.RandomString(32); - await Program.Database.CreateSession(payload.username, token, ip); - } - // If this is the first user, make them an admin - if (await Program.Database.CountUsers() == 1) await Program.Database.UpdateUser(payload.username, newRank: (int)Rank.Admin); - return Results.Json(new RegisterResponse - { - success = true, - verificationRequired = Program.Config.Registration.EmailVerificationRequired, - email = payload.email, - username = payload.username, - sessionToken = token - }, Utilities.JsonSerializerOptions); - } - - private static IResult HandleInfo(HttpContext context) - { - return Results.Json(new AuthServerInformation - { - // TODO: Implement registration closure - registrationOpen = true, - hcaptcha = - new() { - required = Program.Config.hCaptcha.Enabled, - siteKey = Program.Config.hCaptcha.Enabled ? Program.Config.hCaptcha.SiteKey : null - } - }); - } -} \ No newline at end of file diff --git a/CollabVMAuthServer/IConfig.cs b/CollabVMAuthServer/IConfig.cs index feb8f8c..0371d9c 100644 --- a/CollabVMAuthServer/IConfig.cs +++ b/CollabVMAuthServer/IConfig.cs @@ -1,14 +1,30 @@ +using System.IO; +using Computernewb.CollabVMAuthServer.Database; +using Microsoft.EntityFrameworkCore; +using MySqlConnector; +using Tomlet; +using Tomlet.Attributes; + namespace Computernewb.CollabVMAuthServer; public class IConfig { - public RegistrationConfig Registration { get; set; } - public AccountConfig Accounts { get; set; } - public CollabVMConfig CollabVM { get; set; } - public HTTPConfig HTTP { get; set; } - public MySQLConfig MySQL { get; set; } - public SMTPConfig SMTP { get; set; } - public hCaptchaConfig hCaptcha { get; set; } + public RegistrationConfig? Registration { get; set; } + public AccountConfig? Accounts { get; set; } + public CollabVMConfig? CollabVM { get; set; } + public HTTPConfig? HTTP { get; set; } + public MySQLConfig? MySQL { get; set; } + public SMTPConfig? SMTP { get; set; } + public hCaptchaConfig? hCaptcha { get; set; } + + /// Load config instance from the specified toml file + public static IConfig Load(string configPath) { + // Load from disk + var configRaw = File.ReadAllText(configPath); + // Parse toml + var config = TomletMain.To(configRaw); + return config; + } } @@ -16,7 +32,7 @@ public class RegistrationConfig { public bool EmailVerificationRequired { get; set; } public bool EmailDomainWhitelist { get; set; } - public string[] AllowedEmailDomains { get; set; } + public string[]? AllowedEmailDomains { get; set; } } public class AccountConfig @@ -28,21 +44,38 @@ public class AccountConfig public class CollabVMConfig { // We might want to move this to the database, but for now it's fine here. - public string SecretKey { get; set; } + public string? SecretKey { get; set; } } public class HTTPConfig { - public string Host { get; set; } + public string? Host { get; set; } public int Port { get; set; } public bool UseXForwardedFor { get; set; } - public string[] TrustedProxies { get; set; } + public string[]? TrustedProxies { get; set; } } public class MySQLConfig { - public string Host { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string Database { get; set; } + [TomlNonSerialized] + public string ConnectionString => new MySqlConnectionStringBuilder { + Server = Host, + UserID = Username, + Password = Password, + Database = Database + }.ConnectionString; + public string? Host { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? Database { get; set; } + + public DbContextOptionsBuilder Configure(DbContextOptionsBuilder? builder = null) { + return (builder ?? new DbContextOptionsBuilder()) + .UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)); + } + + public DbContextOptionsBuilder Configure(DbContextOptionsBuilder builder) { + return (builder ?? new DbContextOptionsBuilder()) + .UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)); + } } public class SMTPConfig diff --git a/CollabVMAuthServer/IPBan.cs b/CollabVMAuthServer/IPBan.cs deleted file mode 100644 index ea87097..0000000 --- a/CollabVMAuthServer/IPBan.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Net; - -namespace Computernewb.CollabVMAuthServer; - -public class IPBan -{ - public IPAddress IP { get; set; } - public string Reason { get; set; } - public string? BannedBy { get; set; } - public DateTime BannedAt { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/Mailer.cs b/CollabVMAuthServer/Mailer.cs index 14f730e..fda1545 100644 --- a/CollabVMAuthServer/Mailer.cs +++ b/CollabVMAuthServer/Mailer.cs @@ -2,13 +2,15 @@ using System; using System.Threading.Tasks; using MailKit.Net.Smtp; using MailKit.Security; +using Microsoft.Extensions.Logging; using MimeKit; namespace Computernewb.CollabVMAuthServer; public class Mailer { - private SMTPConfig Config; + private readonly SMTPConfig Config; + private readonly ILogger _logger; public Mailer(SMTPConfig config) { if (config.Host == null || config.Port == null || config.Username == null || config.Password == null || @@ -16,10 +18,10 @@ public class Mailer config.VerificationCodeBody == null || config.ResetPasswordSubject == null || config.ResetPasswordBody == null) { - Utilities.Log(LogLevel.FATAL,"SMTPConfig is missing required fields"); - Environment.Exit(1); + throw new InvalidOperationException("SMTPConfig is missing required fields"); } Config = config; + _logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger(); } public async Task SendVerificationCode(string username, string email, string code) @@ -27,23 +29,23 @@ public class Mailer var message = new MimeMessage(); message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail)); message.To.Add(new MailboxAddress(username, email)); - message.Subject = Config.VerificationCodeSubject + message.Subject = Config.VerificationCodeSubject! .Replace("$USERNAME", username) .Replace("$EMAIL", email) .Replace("$CODE", code); message.Body = new TextPart("plain") { - Text = Config.VerificationCodeBody + Text = Config.VerificationCodeBody! .Replace("$USERNAME", username) .Replace("$EMAIL", email) .Replace("$CODE", code) }; using var client = new SmtpClient(); - await client.ConnectAsync(Config.Host, (int)Config.Port, SecureSocketOptions.StartTlsWhenAvailable); + await client.ConnectAsync(Config.Host, (int)Config.Port!, SecureSocketOptions.StartTlsWhenAvailable); await client.AuthenticateAsync(Config.Username, Config.Password); await client.SendAsync(message); await client.DisconnectAsync(true); - Utilities.Log(LogLevel.INFO, $"Sent e-mail verification code to {username} <{email}>"); + _logger.LogInformation("Sent e-mail verification code to {username} <{email}>", username, email); } public async Task SendPasswordResetEmail(string username, string email, string code) @@ -51,23 +53,23 @@ public class Mailer var message = new MimeMessage(); message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail)); message.To.Add(new MailboxAddress(username, email)); - message.Subject = Config.ResetPasswordSubject + message.Subject = Config.ResetPasswordSubject! .Replace("$USERNAME", username) .Replace("$EMAIL", email) .Replace("$CODE", code); message.Body = new TextPart("plain") { - Text = Config.ResetPasswordBody + Text = Config.ResetPasswordBody! .Replace("$USERNAME", username) .Replace("$EMAIL", email) .Replace("$CODE", code) }; using var client = new SmtpClient(); - await client.ConnectAsync(Config.Host, (int)Config.Port, SecureSocketOptions.StartTlsWhenAvailable); + await client.ConnectAsync(Config.Host, (int)Config.Port!, SecureSocketOptions.StartTlsWhenAvailable); await client.AuthenticateAsync(Config.Username, Config.Password); await client.SendAsync(message); await client.DisconnectAsync(true); - Utilities.Log(LogLevel.INFO, $"Sent password reset verification code to {username} <{email}>"); + _logger.LogInformation("Sent password reset verification code to {username} <{email}>", username, email); } } \ No newline at end of file diff --git a/CollabVMAuthServer/Program.cs b/CollabVMAuthServer/Program.cs index 5479f45..99236cd 100644 --- a/CollabVMAuthServer/Program.cs +++ b/CollabVMAuthServer/Program.cs @@ -1,110 +1,176 @@ using System; +using System.CommandLine; using System.IO; +using System.Linq; using System.Net; using System.Reflection; +using System.Text.Json.Serialization; using System.Threading.Tasks; +using Computernewb.CollabVMAuthServer.Database; using Computernewb.CollabVMAuthServer.HTTP; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Tomlet; namespace Computernewb.CollabVMAuthServer; public class Program { + #pragma warning disable CS8618 public static IConfig Config { get; private set; } - public static Database Database { get; private set; } public static hCaptchaClient? hCaptcha { get; private set; } public static Mailer? Mailer { get; private set; } public static string[] BannedPasswords { get; set; } + #pragma warning restore CS8618 public static readonly Random Random = new Random(); - public static async Task Main(string[] args) + private static readonly ILogger _logger + = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger(); + + public static async Task Main(string[] args) { + if (EF.IsDesignTime) { + // We have a design time factory EF uses to handle this, just exit + return 0; + } + + // Root command (run auth server) + var rootCommand = new RootCommand("CollabVM Authentication Server"); + + var configPathOption = new Option( + name: "--config-path", + description: "Configuration file to use", + getDefaultValue: () => "./config.toml" + ); + rootCommand.Add(configPathOption); + + // Migrate DB + var migrateDbCommand = new Command("migrate-db", "Runs all pending database migrations"); + rootCommand.Add(migrateDbCommand); + + rootCommand.SetHandler(RunAuthServer, new AuthServerCliOptionsBinder(configPathOption)); + migrateDbCommand.SetHandler(MigrateDatabase, new AuthServerCliOptionsBinder(configPathOption)); + + return await rootCommand.InvokeAsync(args); + } + + public static async Task MigrateDatabase(AuthServerContext context) { + + _logger.LogInformation("Running database migrations"); + // Initialize database + var db = new CollabVMAuthDbContext(context.Config.MySQL!.Configure().Options); + // Detect and migrate legacy schema + await LegacyDbMigrator.CheckAndMigrate(db); + // Run migrations + _logger.LogInformation("Applying {cnt} migrations now...", (await db.Database.GetPendingMigrationsAsync()).Count()); + await db.Database.MigrateAsync(); + _logger.LogInformation("Finished migrations."); + return 0; + } + + public static async Task RunAuthServer(AuthServerContext context) { var ver = Assembly.GetExecutingAssembly().GetName().Version; - Utilities.Log(LogLevel.INFO, $"CollabVM Authentication Server v{ver.Major}.{ver.Minor}.{ver.Revision} starting up"); - // Read config.toml - string configraw; - try - { - configraw = File.ReadAllText("config.toml"); - } - catch (Exception ex) - { - Utilities.Log(LogLevel.FATAL, "Failed to read config.toml: " + ex.Message); - Environment.Exit(1); - return; - } - // Parse config.toml to IConfig - try - { - Config = TomletMain.To(configraw); - } catch (Exception ex) - { - Utilities.Log(LogLevel.FATAL, "Failed to parse config.toml: " + ex.Message); - Environment.Exit(1); - return; - } + _logger.LogInformation("CollabVM Authentication Server v{major}.{minor}.{revision} starting up", ver!.Major, ver.Minor, ver.Revision); + // temp + Config = context.Config; // Initialize database - Database = new Database(Config.MySQL); - // Get version before initializing - int dbversion = await Database.GetDatabaseVersion(); - Utilities.Log(LogLevel.INFO, "Connected to database"); - Utilities.Log(LogLevel.INFO, dbversion == -1 ? "Initializing tables..." : $"Database version: {dbversion}"); - await Database.Init(); - // If database was version 0, that should now be set, as versioning did not exist then - if (dbversion == 0) await Database.SetDatabaseVersion(0); - // If database was -1, that means it was just initialized and we should set it to the current version - if (dbversion == -1) await Database.SetDatabaseVersion(DatabaseUpdate.CurrentVersion); - // Perform any necessary database updates - await DatabaseUpdate.Update(Database); - var uc = await Database.CountUsers(); - Utilities.Log(LogLevel.INFO, $"{uc} users in database"); - if (uc == 0) Utilities.Log(LogLevel.WARN, "No users in database, first user will be promoted to admin"); + var db = new CollabVMAuthDbContext(context.Config.MySQL!.Configure().Options); + // Make sure database schema is up-to-date, error if not + if ((await db.Database.GetPendingMigrationsAsync()).Any()) { + _logger.LogCritical("Database schema out of date. Please run migrations."); + return 1; + } + // Count users in database + var uc = await db.Users.CountAsync(); + _logger.LogInformation("{uc} users in database", uc); + if (uc == 0) _logger.LogWarning("No users in database, first user will be promoted to admin"); // Init cron - await Cron.Start(); + var cron = new Cron(context.Config.MySQL.Configure().Options); + await cron.Start(); // Create mailer - if (!Config.SMTP.Enabled && Config.Registration.EmailVerificationRequired) + if (!Config.SMTP!.Enabled && Config.Registration!.EmailVerificationRequired) { - Utilities.Log(LogLevel.FATAL, "Email verification is required but SMTP is disabled"); - Environment.Exit(1); - return; + _logger.LogCritical("Email verification is required but SMTP is disabled"); + return 1; } Mailer = Config.SMTP.Enabled ? new Mailer(Config.SMTP) : null; // Create hCaptcha client - if (Config.hCaptcha.Enabled) + if (Config.hCaptcha!.Enabled) { hCaptcha = new hCaptchaClient(Config.hCaptcha.Secret!, Config.hCaptcha.SiteKey!); - Utilities.Log(LogLevel.INFO, "hCaptcha enabled"); + _logger.LogInformation("hCaptcha enabled"); } else { - Utilities.Log(LogLevel.INFO, "hCaptcha disabled"); + _logger.LogInformation("hCaptcha disabled"); } // load password list BannedPasswords = await File.ReadAllLinesAsync("rockyou.txt"); // Configure web server - var builder = WebApplication.CreateBuilder(args); -#if DEBUG - builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug); -#else - builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning); -#endif + var builder = WebApplication.CreateBuilder(); + Utilities.ConfigureLogging(builder.Logging); + + // Configure json serialization + builder.Services.AddControllers().AddJsonOptions((options) => { + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + + // Configure database context + builder.Services.AddDbContext((builder) => context.Config.MySQL.Configure(builder)); + + // Configure forwarded headers + builder.Services.Configure((options) => { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + foreach (var proxy in context.Config.HTTP!.TrustedProxies!) { + options.KnownProxies.Add(IPAddress.Parse(proxy)); + } + }); + + // Configure authentication + builder.Services.AddAuthentication((options) => { + options.DefaultScheme = "CollabVM"; + options.RequireAuthenticatedSignIn = false; + }) + .AddScheme("CollabVM", (options) => { + options.DbContextOptions = context.Config.MySQL.Configure().Options; + }); + + // Configure authorization policies + builder.Services.AddSingleton(); + var authorization = builder.Services.AddAuthorizationBuilder(); + authorization.AddPolicy("User", (policy) => { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("type", "user"); + }); + authorization.AddPolicy("Staff", (policy) => { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("rank", "2", "3"); + }); + authorization.AddPolicy("Developer", (policy) => { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("developer", "1"); + }); + builder.WebHost.UseKestrel(k => { - k.Listen(IPAddress.Parse(Config.HTTP.Host), Config.HTTP.Port); + k.Listen(IPAddress.Parse(Config.HTTP!.Host!), Config.HTTP.Port); }); builder.Services.AddCors(); var app = builder.Build(); + if (context.Config.HTTP!.UseXForwardedFor) { + app.UseForwardedHeaders(); + } app.UseRouting(); // TODO: Make this more strict app.UseCors(cors => cors.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); - app.Lifetime.ApplicationStarted.Register(() => Utilities.Log(LogLevel.INFO, $"Webserver listening on {Config.HTTP.Host}:{Config.HTTP.Port}")); + app.UseAuthentication(); + app.UseAuthorization(); // Register routes - Routes.RegisterRoutes(app); - AdminRoutes.RegisterRoutes(app); - DeveloperRoutes.RegisterRoutes(app); - app.Run(); + app.MapControllers(); + await app.RunAsync(); + return 0; } } \ No newline at end of file diff --git a/CollabVMAuthServer/Session.cs b/CollabVMAuthServer/Session.cs deleted file mode 100644 index 3b9654c..0000000 --- a/CollabVMAuthServer/Session.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Net; - -namespace Computernewb.CollabVMAuthServer; - -public class Session -{ - public string Token { get; set; } - public string Username { get; set; } - public DateTime Created { get; set; } - public DateTime LastUsed { get; set; } - public IPAddress LastIP { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/StaffMember.cs b/CollabVMAuthServer/StaffMember.cs deleted file mode 100644 index e6d3145..0000000 --- a/CollabVMAuthServer/StaffMember.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Computernewb.CollabVMAuthServer; - -public class StaffMember -{ - public string Username { get; set; } - public Rank Rank { get; set; } -} \ No newline at end of file diff --git a/CollabVMAuthServer/User.cs b/CollabVMAuthServer/User.cs deleted file mode 100644 index 22e570c..0000000 --- a/CollabVMAuthServer/User.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Net; - -namespace Computernewb.CollabVMAuthServer; - -public class User -{ - public uint Id { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string Email { get; set; } - public DateOnly DateOfBirth { get; set; } - public bool EmailVerified { get; set; } - public string? EmailVerificationCode { get; set; } - public string? PasswordResetCode { get; set; } - public Rank Rank { get; set; } - public bool Banned { get; set; } - public string? BanReason { get; set; } - public IPAddress RegistrationIP { get; set; } - public DateTime Joined { get; set; } - public bool Developer { get; set; } -} - -public enum Rank : uint -{ - Registered = 1, - Admin = 2, - Moderator = 3, -} \ No newline at end of file diff --git a/CollabVMAuthServer/Utilities.cs b/CollabVMAuthServer/Utilities.cs index 4aa71a3..6d143e6 100644 --- a/CollabVMAuthServer/Utilities.cs +++ b/CollabVMAuthServer/Utilities.cs @@ -1,75 +1,20 @@ using System; -using System.Linq; -using System.Net; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace Computernewb.CollabVMAuthServer; -public enum LogLevel -{ - DEBUG, - INFO, - WARN, - ERROR, - FATAL -} - - public static class Utilities { - public static JsonSerializerOptions JsonSerializerOptions => new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - public static void Log(LogLevel level, string msg) - { -#if !DEBUG - if (level == LogLevel.DEBUG) - return; -#endif - StringBuilder logstr = new StringBuilder(); - logstr.Append("["); - logstr.Append(DateTime.Now.ToString("G")); - logstr.Append("] ["); - switch (level) - { - case LogLevel.DEBUG: - logstr.Append("DEBUG"); - break; - case LogLevel.INFO: - logstr.Append("INFO"); - break; - case LogLevel.WARN: - logstr.Append("WARN"); - break; - case LogLevel.ERROR: - logstr.Append("ERROR"); - break; - case LogLevel.FATAL: - logstr.Append("FATAL"); - break; - default: - throw new ArgumentException("Invalid log level"); - } - logstr.Append("] "); - logstr.Append(msg); - switch (level) - { - case LogLevel.DEBUG: - case LogLevel.INFO: - Console.WriteLine(logstr.ToString()); - break; - case LogLevel.WARN: - case LogLevel.ERROR: - case LogLevel.FATAL: - Console.Error.WriteLine(logstr.ToString()); - break; - } + public static void ConfigureLogging(ILoggingBuilder builder) { + builder.ClearProviders(); + builder.AddConsole(); + #if DEBUG + builder.SetMinimumLevel(LogLevel.Debug); + #else + builder.SetMinimumLevel(LogLevel.Warning); + #endif } public static bool ValidateUsername(string username) @@ -101,64 +46,4 @@ public static class Utilities } return str.ToString(); } - - public static IPAddress? GetIP(HttpContext ctx) - { - if (Program.Config.HTTP.UseXForwardedFor) - { - if (!Program.Config.HTTP.TrustedProxies.Contains(ctx.Connection.RemoteIpAddress.ToString())) - { - Utilities.Log(LogLevel.WARN, - $"An IP address not allowed to proxy connections ({ctx.Connection.RemoteIpAddress.ToString()}) attempted to connect. This means your server port is exposed to the internet."); - return null; - } - - if (ctx.Request.Headers["X-Forwarded-For"].Count == 0) - { - Utilities.Log(LogLevel.WARN, $"Missing X-Forwarded-For header in request from {ctx.Connection.RemoteIpAddress.ToString()}. This is probably a misconfiguration of your reverse proxy."); - return null; - } - - if (!IPAddress.TryParse(ctx.Request.Headers["X-Forwarded-For"][0], out var ip)) return null; - return ip; - } - else return ctx.Connection.RemoteIpAddress; - } - - public static bool IsSessionExpired(Session session) - { - return DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts.SessionExpiryDays); - } - - public static async Task GetStaffByToken(string token) - { - if (token.Length == 32) - { - // User - var session = await Program.Database.GetSession(token); - if (session == null || Utilities.IsSessionExpired(session)) return null; - var user = await Program.Database.GetUser(session.Username); - if (user == null) return null; - return new StaffMember - { - Username = user.Username, - Rank = user.Rank - }; - } - else if (token.Length == 64) - { - // Bot - var bot = await Program.Database.GetBot(token: token); - if (bot == null) return null; - return new StaffMember - { - Username = bot.Username, - Rank = bot.Rank - }; - } - else - { - return null; - } - } } \ No newline at end of file diff --git a/CollabVMAuthServer/hCaptchaClient.cs b/CollabVMAuthServer/hCaptchaClient.cs index f76510a..e1198a7 100644 --- a/CollabVMAuthServer/hCaptchaClient.cs +++ b/CollabVMAuthServer/hCaptchaClient.cs @@ -36,8 +36,8 @@ public class hCaptchaClient public class hCaptchaResponse { public bool success { get; set; } - public string challenge_ts { get; set; } - public string hostname { get; set; } + public string? challenge_ts { get; set; } + public string? hostname { get; set; } public bool? credit { get; set; } [JsonPropertyName("error-codes")] public string[]? error_codes { get; set; }