From d8ba680d3477eec430fb72302aabc9742edaa4f1 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Fri, 5 Apr 2024 08:25:18 -0400 Subject: [PATCH] implement a bunch more routes and functionality --- CollabVMAuthServer/Database.cs | 140 +++++++- CollabVMAuthServer/IConfig.cs | 17 + CollabVMAuthServer/JoinPayload.cs | 8 + CollabVMAuthServer/JoinResponse.cs | 10 + CollabVMAuthServer/LoginPayload.cs | 8 + CollabVMAuthServer/LoginResponse.cs | 11 + CollabVMAuthServer/LogoutPayload.cs | 6 + CollabVMAuthServer/LogoutResponse.cs | 7 + CollabVMAuthServer/Program.cs | 8 +- CollabVMAuthServer/RegisterResponse.cs | 1 + CollabVMAuthServer/Routes.cs | 450 ++++++++++++++++++++++++- CollabVMAuthServer/Session.cs | 5 +- CollabVMAuthServer/SessionPayload.cs | 6 + CollabVMAuthServer/SessionResponse.cs | 10 + CollabVMAuthServer/UpdatePayload.cs | 11 + CollabVMAuthServer/UpdateResponse.cs | 9 + CollabVMAuthServer/User.cs | 3 + CollabVMAuthServer/Utilities.cs | 36 ++ CollabVMAuthServer/VerifyResponse.cs | 8 + 19 files changed, 736 insertions(+), 18 deletions(-) create mode 100644 CollabVMAuthServer/JoinPayload.cs create mode 100644 CollabVMAuthServer/JoinResponse.cs create mode 100644 CollabVMAuthServer/LoginPayload.cs create mode 100644 CollabVMAuthServer/LoginResponse.cs create mode 100644 CollabVMAuthServer/LogoutPayload.cs create mode 100644 CollabVMAuthServer/LogoutResponse.cs create mode 100644 CollabVMAuthServer/SessionPayload.cs create mode 100644 CollabVMAuthServer/SessionResponse.cs create mode 100644 CollabVMAuthServer/UpdatePayload.cs create mode 100644 CollabVMAuthServer/UpdateResponse.cs create mode 100644 CollabVMAuthServer/VerifyResponse.cs diff --git a/CollabVMAuthServer/Database.cs b/CollabVMAuthServer/Database.cs index c739efd..6d14d34 100644 --- a/CollabVMAuthServer/Database.cs +++ b/CollabVMAuthServer/Database.cs @@ -1,3 +1,4 @@ +using System.Net; using Isopoh.Cryptography.Argon2; using MySqlConnector; @@ -31,8 +32,9 @@ public class Database email TEXT NOT NULL UNIQUE KEY, email_verified BOOLEAN NOT NULL DEFAULT 0, email_verification_code CHAR(8) DEFAULT NULL, - cvm_rank INT UNSIGNED NOT NULL DEFAULT 0, - banned BOOLEAN NOT NULL DEFAULT 0 + cvm_rank INT UNSIGNED NOT NULL DEFAULT 1, + banned BOOLEAN NOT NULL DEFAULT 0, + registration_ip VARBINARY(16) NOT NULL ); """; await cmd.ExecuteNonQueryAsync(); @@ -42,6 +44,7 @@ public class Database 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 ) """; @@ -77,11 +80,12 @@ public class Database EmailVerified = reader.GetBoolean("email_verified"), EmailVerificationCode = reader.GetString("email_verification_code"), Rank = (Rank)reader.GetUInt32("cvm_rank"), - Banned = reader.GetBoolean("banned") + Banned = reader.GetBoolean("banned"), + RegistrationIP = new IPAddress(await reader.GetFieldValueAsync(8)) }; } - public async Task RegisterAccount(string username, string email, string password, bool verified, + public async Task RegisterAccount(string username, string email, string password, bool verified, IPAddress ip, string? verificationcode = null) { await using var db = new MySqlConnection(connectionString); @@ -89,15 +93,16 @@ public class Database await using var cmd = db.CreateCommand(); cmd.CommandText = """ INSERT INTO users - (username, password, email, email_verified, email_verification_code) + (username, password, email, email_verified, email_verification_code, registration_ip) VALUES - (@username, @password, @email, @email_verified, @email_verification_code) + (@username, @password, @email, @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("@email_verified", verified); cmd.Parameters.AddWithValue("@email_verification_code", verificationcode); + cmd.Parameters.AddWithValue("@registration_ip", ip.GetAddressBytes()); await cmd.ExecuteNonQueryAsync(); } @@ -111,4 +116,127 @@ public class Database 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(await reader.GetFieldValueAsync(4)) + }); + } + 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(await reader.GetFieldValueAsync(4)) + }; + } + + 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, string? newPassword, string? newEmail) + { + 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); + } + cmd.CommandText = $"UPDATE users SET {string.Join(", ", updates)} WHERE username = @username"; + cmd.Parameters.AddWithValue("@username", username); + await cmd.ExecuteNonQueryAsync(); + } } \ No newline at end of file diff --git a/CollabVMAuthServer/IConfig.cs b/CollabVMAuthServer/IConfig.cs index 9089f0d..4fb9e9d 100644 --- a/CollabVMAuthServer/IConfig.cs +++ b/CollabVMAuthServer/IConfig.cs @@ -3,10 +3,13 @@ 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 class RegistrationConfig @@ -15,10 +18,24 @@ public class RegistrationConfig public bool EmailDomainWhitelist { get; set; } public string[] AllowedEmailDomains { get; set; } } + +public class AccountConfig +{ + public int MaxSessions { get; set; } + public int SessionExpiryDays { get; set; } +} + +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 class HTTPConfig { public string Host { get; set; } public int Port { get; set; } + public bool UseXForwardedFor { get; set; } + public string[] TrustedProxies { get; set; } } public class MySQLConfig { diff --git a/CollabVMAuthServer/JoinPayload.cs b/CollabVMAuthServer/JoinPayload.cs new file mode 100644 index 0000000..d77e668 --- /dev/null +++ b/CollabVMAuthServer/JoinPayload.cs @@ -0,0 +1,8 @@ +namespace Computernewb.CollabVMAuthServer; + +public class JoinPayload +{ + public string secretKey { get; set; } + public string sessionToken { get; set; } + public string ip { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/JoinResponse.cs b/CollabVMAuthServer/JoinResponse.cs new file mode 100644 index 0000000..90c8373 --- /dev/null +++ b/CollabVMAuthServer/JoinResponse.cs @@ -0,0 +1,10 @@ +namespace Computernewb.CollabVMAuthServer; + +public class JoinResponse +{ + public bool success { get; set; } + public bool clientSuccess { get; set; } = false; + public string? error { get; set; } + public string? username { get; set; } + public Rank? rank { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/LoginPayload.cs b/CollabVMAuthServer/LoginPayload.cs new file mode 100644 index 0000000..ac7c124 --- /dev/null +++ b/CollabVMAuthServer/LoginPayload.cs @@ -0,0 +1,8 @@ +namespace Computernewb.CollabVMAuthServer; + +public class LoginPayload +{ + public string username { get; set; } + public string password { get; set; } + public string? captchaToken { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/LoginResponse.cs b/CollabVMAuthServer/LoginResponse.cs new file mode 100644 index 0000000..fbf5bee --- /dev/null +++ b/CollabVMAuthServer/LoginResponse.cs @@ -0,0 +1,11 @@ +namespace Computernewb.CollabVMAuthServer; + +public class LoginResponse +{ + 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; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/LogoutPayload.cs b/CollabVMAuthServer/LogoutPayload.cs new file mode 100644 index 0000000..9be9546 --- /dev/null +++ b/CollabVMAuthServer/LogoutPayload.cs @@ -0,0 +1,6 @@ +namespace Computernewb.CollabVMAuthServer; + +public class LogoutPayload +{ + public string token { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/LogoutResponse.cs b/CollabVMAuthServer/LogoutResponse.cs new file mode 100644 index 0000000..b0a5e55 --- /dev/null +++ b/CollabVMAuthServer/LogoutResponse.cs @@ -0,0 +1,7 @@ +namespace Computernewb.CollabVMAuthServer; + +public class LogoutResponse +{ + public bool success { get; set; } + public string? error { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/Program.cs b/CollabVMAuthServer/Program.cs index d326f92..903b15a 100644 --- a/CollabVMAuthServer/Program.cs +++ b/CollabVMAuthServer/Program.cs @@ -56,14 +56,20 @@ public class Program BannedPasswords = await File.ReadAllLinesAsync("rockyou.txt"); // Configure web server var builder = WebApplication.CreateBuilder(args); -#if !DEBUG +#if DEBUG + builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug); +#else builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning); #endif builder.WebHost.UseKestrel(k => { k.Listen(IPAddress.Parse(Config.HTTP.Host), Config.HTTP.Port); }); + builder.Services.AddCors(); var app = builder.Build(); + 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}")); // Register routes Routes.RegisterRoutes(app); diff --git a/CollabVMAuthServer/RegisterResponse.cs b/CollabVMAuthServer/RegisterResponse.cs index 2f2e01a..51bab1d 100644 --- a/CollabVMAuthServer/RegisterResponse.cs +++ b/CollabVMAuthServer/RegisterResponse.cs @@ -7,4 +7,5 @@ public class RegisterResponse public bool? verificationRequired { get; set; } = null; public string? username { get; set; } public string? email { get; set; } + public string? sessionToken { get; set; } } \ No newline at end of file diff --git a/CollabVMAuthServer/Routes.cs b/CollabVMAuthServer/Routes.cs index fd0a284..2c18d45 100644 --- a/CollabVMAuthServer/Routes.cs +++ b/CollabVMAuthServer/Routes.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; using Isopoh.Cryptography.Argon2; +using Microsoft.AspNetCore.Http.HttpResults; namespace Computernewb.CollabVMAuthServer; @@ -12,6 +14,415 @@ public static class Routes 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); + } + + 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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 (!string.IsNullOrWhiteSpace(payload.username) && !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); + } + // Validate new E-Mail + if (!string.IsNullOrWhiteSpace(payload.email) && !new EmailAddressAttribute().IsValid(payload.email)) + { + return Results.Json(new UpdateResponse + { + success = false, + error = "Malformed E-Mail address." + }, Utilities.JsonSerializerOptions); + } + if (!string.IsNullOrWhiteSpace(payload.email) && 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); + } + // Make sure username isn't taken + var _user = await Program.Database.GetUser(payload.username); + if (_user != 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 new password + if (!string.IsNullOrWhiteSpace(payload.newPassword)) + { + 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 || + 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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 (DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts.SessionExpiryDays)) + { + 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 + }, 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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 session is valid + 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, + error = "You are banned", + }, 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); + } + + 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); + } + var payload = await context.Request.ReadFromJsonAsync(); + 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 account is verified + if (!user.EmailVerified) + { + return Results.Json(new LoginResponse + { + success = true, + verificationRequired = true, + email = user.Email, + username = user.Username, + }); + } + // 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 + }, Utilities.JsonSerializerOptions); } private static async Task HandleVerify(HttpContext context) @@ -20,7 +431,7 @@ public static class Routes if (context.Request.ContentType != "application/json") { context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse + return Results.Json(new VerifyResponse { success = false, error = "Invalid request body" @@ -32,7 +443,7 @@ public static class Routes string.IsNullOrWhiteSpace(payload.password) || string.IsNullOrWhiteSpace(payload.password)) { context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse + return Results.Json(new VerifyResponse { success = false, error = "Invalid request body" @@ -43,7 +454,7 @@ public static class Routes if (user == null || !Argon2.Verify(user.Password, payload.password)) { context.Response.StatusCode = 403; - return Results.Json(new RegisterResponse + return Results.Json(new VerifyResponse { success = false, error = "Invalid username or password" @@ -53,7 +464,7 @@ public static class Routes if (user.EmailVerified) { context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse + return Results.Json(new VerifyResponse { success = false, error = "Account is already verified" @@ -63,7 +474,7 @@ public static class Routes if (user.EmailVerificationCode != payload.code) { context.Response.StatusCode = 400; - return Results.Json(new RegisterResponse + return Results.Json(new VerifyResponse { success = false, error = "Invalid verification code" @@ -71,14 +482,30 @@ public static class Routes } // Verify the account await Program.Database.SetUserVerified(payload.username, true); - return Results.Json(new RegisterResponse + // Create a session + var token = Utilities.RandomString(32); + var ip = Utilities.GetIP(context); + if (ip == null) { - success = true + 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") { @@ -112,7 +539,7 @@ public static class Routes }, Utilities.JsonSerializerOptions); } var result = - await Program.hCaptcha!.Verify(payload.captchaToken, context.Connection.RemoteIpAddress!.ToString()); + await Program.hCaptcha!.Verify(payload.captchaToken, ip.ToString()); if (!result.success) { context.Response.StatusCode = 400; @@ -198,7 +625,7 @@ public static class Routes if (Program.Config.Registration.EmailVerificationRequired) { var code = Program.Random.Next(10000000, 99999999).ToString(); - await Program.Database.RegisterAccount(payload.username, payload.email, payload.password, false, code); + await Program.Database.RegisterAccount(payload.username, payload.email, payload.password, false, ip,code); await Program.Mailer.SendVerificationCode(payload.username, payload.email, code); return Results.Json(new RegisterResponse { @@ -211,12 +638,15 @@ public static class Routes else { await Program.Database.RegisterAccount(payload.username, payload.email, payload.password, true, null); + var token = Utilities.RandomString(32); + await Program.Database.CreateSession(user.Username, token, ip); return Results.Json(new RegisterResponse { success = true, verificationRequired = false, email = payload.email, - username = payload.username + username = payload.username, + sessionToken = token }, Utilities.JsonSerializerOptions); } } diff --git a/CollabVMAuthServer/Session.cs b/CollabVMAuthServer/Session.cs index df92593..b52c6a3 100644 --- a/CollabVMAuthServer/Session.cs +++ b/CollabVMAuthServer/Session.cs @@ -1,9 +1,12 @@ +using System.Net; + namespace Computernewb.CollabVMAuthServer; public class Session { public string Token { get; set; } - public uint UserId { 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/SessionPayload.cs b/CollabVMAuthServer/SessionPayload.cs new file mode 100644 index 0000000..85d2b87 --- /dev/null +++ b/CollabVMAuthServer/SessionPayload.cs @@ -0,0 +1,6 @@ +namespace Computernewb.CollabVMAuthServer; + +public class SessionPayload +{ + public string token { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/SessionResponse.cs b/CollabVMAuthServer/SessionResponse.cs new file mode 100644 index 0000000..6b82101 --- /dev/null +++ b/CollabVMAuthServer/SessionResponse.cs @@ -0,0 +1,10 @@ +namespace Computernewb.CollabVMAuthServer; + +public class SessionResponse +{ + 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; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/UpdatePayload.cs b/CollabVMAuthServer/UpdatePayload.cs new file mode 100644 index 0000000..7aa4f84 --- /dev/null +++ b/CollabVMAuthServer/UpdatePayload.cs @@ -0,0 +1,11 @@ +namespace Computernewb.CollabVMAuthServer; + +public class UpdatePayload +{ + public string token { get; set; } + public string currentPassword { get; set; } + + public string? newPassword { get; set; } + public string? username { get; set; } + public string? email { get; set; } +} \ No newline at end of file diff --git a/CollabVMAuthServer/UpdateResponse.cs b/CollabVMAuthServer/UpdateResponse.cs new file mode 100644 index 0000000..e389d15 --- /dev/null +++ b/CollabVMAuthServer/UpdateResponse.cs @@ -0,0 +1,9 @@ +namespace Computernewb.CollabVMAuthServer; + +public class UpdateResponse +{ + 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/User.cs b/CollabVMAuthServer/User.cs index e1ac7c4..e21bb89 100644 --- a/CollabVMAuthServer/User.cs +++ b/CollabVMAuthServer/User.cs @@ -1,3 +1,5 @@ +using System.Net; + namespace Computernewb.CollabVMAuthServer; public class User @@ -10,6 +12,7 @@ public class User public string EmailVerificationCode { get; set; } public Rank Rank { get; set; } public bool Banned { get; set; } + public IPAddress RegistrationIP { get; set; } } public enum Rank : uint diff --git a/CollabVMAuthServer/Utilities.cs b/CollabVMAuthServer/Utilities.cs index 922ebf4..2cb8913 100644 --- a/CollabVMAuthServer/Utilities.cs +++ b/CollabVMAuthServer/Utilities.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -84,4 +85,39 @@ public static class Utilities new Regex("[!@#$%^&*()\\-_=+\\\\|\\[\\];:'\\\",<.>/?`~]").IsMatch(password) && new Regex("[0-9]").IsMatch(password); } + + public static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + StringBuilder str = new StringBuilder(); + Random rand = new Random(); + for (int i = 0; i < length; i++) + { + str.Append(chars[rand.Next(chars.Length)]); + } + 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; + } } \ No newline at end of file diff --git a/CollabVMAuthServer/VerifyResponse.cs b/CollabVMAuthServer/VerifyResponse.cs new file mode 100644 index 0000000..0859390 --- /dev/null +++ b/CollabVMAuthServer/VerifyResponse.cs @@ -0,0 +1,8 @@ +namespace Computernewb.CollabVMAuthServer; + +public class VerifyResponse +{ + public bool success { get; set; } + public string? error { get; set; } + public string? sessionToken { get; set; } +} \ No newline at end of file