diff --git a/CollabVMAuthServer/Database.cs b/CollabVMAuthServer/Database.cs index 4b76a65..c353f83 100644 --- a/CollabVMAuthServer/Database.cs +++ b/CollabVMAuthServer/Database.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Net; using Isopoh.Cryptography.Argon2; using MySqlConnector; @@ -33,6 +34,7 @@ public class Database 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, registration_ip VARBINARY(16) NOT NULL @@ -50,6 +52,16 @@ public class Database ) """; 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(); } public async Task GetUser(string? username = null, string? email = null) @@ -80,10 +92,11 @@ public class Database Email = reader.GetString("email"), DateOfBirth = reader.GetDateOnly("date_of_birth"), EmailVerified = reader.GetBoolean("email_verified"), - EmailVerificationCode = reader.GetString("email_verification_code"), + 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"), - RegistrationIP = new IPAddress(await reader.GetFieldValueAsync(8)) + RegistrationIP = new IPAddress(reader.GetFieldValue("registration_ip")) }; } @@ -97,12 +110,12 @@ public class Database 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) + (@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.AddWithValue("@date_of_birth", dateOfBirth); + 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()); @@ -160,7 +173,7 @@ public class Database Username = reader.GetString("username"), Created = reader.GetDateTime("created"), LastUsed = reader.GetDateTime("last_used"), - LastIP = new IPAddress(await reader.GetFieldValueAsync(4)) + LastIP = new IPAddress(reader.GetFieldValue("last_ip")) }); } return sessions.ToArray(); @@ -182,7 +195,7 @@ public class Database Username = reader.GetString("username"), Created = reader.GetDateTime("created"), LastUsed = reader.GetDateTime("last_used"), - LastIP = new IPAddress(await reader.GetFieldValueAsync(4)) + LastIP = new IPAddress(reader.GetFieldValue("last_ip")) }; } @@ -217,7 +230,7 @@ public class Database await cmd.ExecuteNonQueryAsync(); } - public async Task UpdateUser(string username, string? newUsername, string? newPassword, string? newEmail) + public async Task UpdateUser(string username, string? newUsername = null, string? newPassword = null, string? newEmail = null) { await using var db = new MySqlConnection(connectionString); await db.OpenAsync(); @@ -242,4 +255,56 @@ public class Database 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(); + } } \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs b/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs new file mode 100644 index 0000000..221559b --- /dev/null +++ b/CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs @@ -0,0 +1,9 @@ +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; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs b/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs new file mode 100644 index 0000000..d1dcf42 --- /dev/null +++ b/CollabVMAuthServer/HTTP/Payloads/SendResetEmailPayload.cs @@ -0,0 +1,8 @@ +namespace Computernewb.CollabVMAuthServer.HTTP.Payloads; + +public class SendResetEmailPayload +{ + public string email { get; set; } + public string username { get; set; } + public string? captchaToken { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs b/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs new file mode 100644 index 0000000..5f119f6 --- /dev/null +++ b/CollabVMAuthServer/HTTP/Responses/ResetPasswordResponse.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..b959585 --- /dev/null +++ b/CollabVMAuthServer/HTTP/Responses/SendResetEmailResponse.cs @@ -0,0 +1,7 @@ +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/IConfig.cs b/CollabVMAuthServer/IConfig.cs index 4fb9e9d..931339c 100644 --- a/CollabVMAuthServer/IConfig.cs +++ b/CollabVMAuthServer/IConfig.cs @@ -55,6 +55,8 @@ public class SMTPConfig public string FromEmail { get; set; } public string VerificationCodeSubject { get; set; } public string VerificationCodeBody { get; set; } + public string ResetPasswordSubject { get; set; } + public string ResetPasswordBody { get; set; } } public class hCaptchaConfig diff --git a/CollabVMAuthServer/IPBan.cs b/CollabVMAuthServer/IPBan.cs new file mode 100644 index 0000000..67193f1 --- /dev/null +++ b/CollabVMAuthServer/IPBan.cs @@ -0,0 +1,11 @@ +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 2ed2c03..5754b8e 100644 --- a/CollabVMAuthServer/Mailer.cs +++ b/CollabVMAuthServer/Mailer.cs @@ -33,6 +33,31 @@ public class Mailer await client.AuthenticateAsync(Config.Username, Config.Password); await client.SendAsync(message); await client.DisconnectAsync(true); - Utilities.Log(LogLevel.INFO, $"Sent verification code to {username} <{email}>"); + Utilities.Log(LogLevel.INFO, $"Sent e-mail verification code to {username} <{email}>"); } + + public async Task SendPasswordResetEmail(string username, string email, string code) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail)); + message.To.Add(new MailboxAddress(username, email)); + message.Subject = Config.ResetPasswordSubject + .Replace("$USERNAME", username) + .Replace("$EMAIL", email) + .Replace("$CODE", code); + message.Body = new TextPart("plain") + { + Text = Config.ResetPasswordBody + .Replace("$USERNAME", username) + .Replace("$EMAIL", email) + .Replace("$CODE", code) + }; + using var client = new SmtpClient(); + await client.ConnectAsync(Config.Host, 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}>"); + } + } \ No newline at end of file diff --git a/CollabVMAuthServer/Routes.cs b/CollabVMAuthServer/Routes.cs index 0005d2a..62707f0 100644 --- a/CollabVMAuthServer/Routes.cs +++ b/CollabVMAuthServer/Routes.cs @@ -22,6 +22,155 @@ public static class Routes 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) + { + // 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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); + } + // 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) @@ -296,6 +445,27 @@ public static class Routes 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 = "You are banned" + }, Utilities.JsonSerializerOptions); + } // Check if session is valid var session = await Program.Database.GetSession(payload.sessionToken); if (session == null) @@ -403,6 +573,17 @@ public static class Routes 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) { @@ -534,6 +715,17 @@ public static class Routes 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) { @@ -643,12 +835,23 @@ public static class Routes 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 must be at least 13 years old to register." + 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 if (Program.Config.Registration.EmailVerificationRequired) { diff --git a/CollabVMAuthServer/User.cs b/CollabVMAuthServer/User.cs index 72d0e5b..24943ad 100644 --- a/CollabVMAuthServer/User.cs +++ b/CollabVMAuthServer/User.cs @@ -10,7 +10,8 @@ public class User public string Email { get; set; } public DateOnly DateOfBirth { get; set; } public bool EmailVerified { get; set; } - public string EmailVerificationCode { get; set; } + public string? EmailVerificationCode { get; set; } + public string? PasswordResetCode { get; set; } public Rank Rank { get; set; } public bool Banned { get; set; } public IPAddress RegistrationIP { get; set; }