implement IP bans and password resets
This commit is contained in:
parent
0b1ec748da
commit
e19401fb9b
10 changed files with 348 additions and 10 deletions
|
@ -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();
|
||||
}
|
||||
}
|
9
CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs
Normal file
9
CollabVMAuthServer/HTTP/Payloads/ResetPasswordPayload.cs
Normal 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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
|
||||
|
||||
public class ResetPasswordResponse
|
||||
{
|
||||
public bool success { get; set; }
|
||||
public string? error { get; set; }
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
|
||||
|
||||
public class SendResetEmailResponse
|
||||
{
|
||||
public bool success { get; set; }
|
||||
public string? error { get; set; }
|
||||
}
|
|
@ -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
|
||||
|
|
11
CollabVMAuthServer/IPBan.cs
Normal file
11
CollabVMAuthServer/IPBan.cs
Normal 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; }
|
||||
}
|
|
@ -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}>");
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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; }
|
||||
|
|
Loading…
Reference in a new issue