implement IP bans and password resets

This commit is contained in:
Elijah R 2024-04-05 20:08:23 -04:00
parent 0b1ec748da
commit e19401fb9b
10 changed files with 348 additions and 10 deletions

View file

@ -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<User?> 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<byte[]>(8))
RegistrationIP = new IPAddress(reader.GetFieldValue<byte[]>("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<byte[]>(4))
LastIP = new IPAddress(reader.GetFieldValue<byte[]>("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<byte[]>(4))
LastIP = new IPAddress(reader.GetFieldValue<byte[]>("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<IPBan?> 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<byte[]>("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();
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class ResetPasswordResponse
{
public bool success { get; set; }
public string? error { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class SendResetEmailResponse
{
public bool success { get; set; }
public string? error { get; set; }
}

View file

@ -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

View file

@ -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; }
}

View file

@ -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}>");
}
}

View file

@ -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<IResult> 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<SendResetEmailPayload>();
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<IResult> 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<ResetPasswordPayload>();
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<IResult> 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)
{

View file

@ -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; }