implement a bunch more routes and functionality

This commit is contained in:
Elijah R 2024-04-05 08:25:18 -04:00
parent 16acd9b772
commit d8ba680d34
19 changed files with 736 additions and 18 deletions

View file

@ -1,3 +1,4 @@
using System.Net;
using Isopoh.Cryptography.Argon2; using Isopoh.Cryptography.Argon2;
using MySqlConnector; using MySqlConnector;
@ -31,8 +32,9 @@ public class Database
email TEXT NOT NULL UNIQUE KEY, email TEXT NOT NULL UNIQUE KEY,
email_verified BOOLEAN NOT NULL DEFAULT 0, email_verified BOOLEAN NOT NULL DEFAULT 0,
email_verification_code CHAR(8) DEFAULT NULL, email_verification_code CHAR(8) DEFAULT NULL,
cvm_rank INT UNSIGNED NOT NULL DEFAULT 0, cvm_rank INT UNSIGNED NOT NULL DEFAULT 1,
banned BOOLEAN NOT NULL DEFAULT 0 banned BOOLEAN NOT NULL DEFAULT 0,
registration_ip VARBINARY(16) NOT NULL
); );
"""; """;
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
@ -42,6 +44,7 @@ public class Database
username VARCHAR(20) NOT NULL, username VARCHAR(20) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used 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 FOREIGN KEY (username) REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE
) )
"""; """;
@ -77,11 +80,12 @@ public class Database
EmailVerified = reader.GetBoolean("email_verified"), EmailVerified = reader.GetBoolean("email_verified"),
EmailVerificationCode = reader.GetString("email_verification_code"), EmailVerificationCode = reader.GetString("email_verification_code"),
Rank = (Rank)reader.GetUInt32("cvm_rank"), Rank = (Rank)reader.GetUInt32("cvm_rank"),
Banned = reader.GetBoolean("banned") Banned = reader.GetBoolean("banned"),
RegistrationIP = new IPAddress(await reader.GetFieldValueAsync<byte[]>(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) string? verificationcode = null)
{ {
await using var db = new MySqlConnection(connectionString); await using var db = new MySqlConnection(connectionString);
@ -89,15 +93,16 @@ public class Database
await using var cmd = db.CreateCommand(); await using var cmd = db.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO users INSERT INTO users
(username, password, email, email_verified, email_verification_code) (username, password, email, email_verified, email_verification_code, registration_ip)
VALUES 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("@username", username);
cmd.Parameters.AddWithValue("@password", Argon2.Hash(password)); cmd.Parameters.AddWithValue("@password", Argon2.Hash(password));
cmd.Parameters.AddWithValue("@email", email); cmd.Parameters.AddWithValue("@email", email);
cmd.Parameters.AddWithValue("@email_verified", verified); cmd.Parameters.AddWithValue("@email_verified", verified);
cmd.Parameters.AddWithValue("@email_verification_code", verificationcode); cmd.Parameters.AddWithValue("@email_verification_code", verificationcode);
cmd.Parameters.AddWithValue("@registration_ip", ip.GetAddressBytes());
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
} }
@ -111,4 +116,127 @@ public class Database
cmd.Parameters.AddWithValue("@username", username); cmd.Parameters.AddWithValue("@username", username);
await cmd.ExecuteNonQueryAsync(); 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<Session[]> 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<Session>();
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<byte[]>(4))
});
}
return sessions.ToArray();
}
public async Task<Session?> 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<byte[]>(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<string>();
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();
}
} }

View file

@ -3,10 +3,13 @@ namespace Computernewb.CollabVMAuthServer;
public class IConfig public class IConfig
{ {
public RegistrationConfig Registration { get; set; } public RegistrationConfig Registration { get; set; }
public AccountConfig Accounts { get; set; }
public CollabVMConfig CollabVM { get; set; }
public HTTPConfig HTTP { get; set; } public HTTPConfig HTTP { get; set; }
public MySQLConfig MySQL { get; set; } public MySQLConfig MySQL { get; set; }
public SMTPConfig SMTP { get; set; } public SMTPConfig SMTP { get; set; }
public hCaptchaConfig hCaptcha { get; set; } public hCaptchaConfig hCaptcha { get; set; }
} }
public class RegistrationConfig public class RegistrationConfig
@ -15,10 +18,24 @@ public class RegistrationConfig
public bool EmailDomainWhitelist { get; set; } public bool EmailDomainWhitelist { get; set; }
public string[] AllowedEmailDomains { 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 class HTTPConfig
{ {
public string Host { get; set; } public string Host { get; set; }
public int Port { get; set; } public int Port { get; set; }
public bool UseXForwardedFor { get; set; }
public string[] TrustedProxies { get; set; }
} }
public class MySQLConfig public class MySQLConfig
{ {

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
namespace Computernewb.CollabVMAuthServer;
public class LogoutPayload
{
public string token { get; set; }
}

View file

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

View file

@ -56,14 +56,20 @@ public class Program
BannedPasswords = await File.ReadAllLinesAsync("rockyou.txt"); BannedPasswords = await File.ReadAllLinesAsync("rockyou.txt");
// Configure web server // Configure web server
var builder = WebApplication.CreateBuilder(args); 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); builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning);
#endif #endif
builder.WebHost.UseKestrel(k => 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(); 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}")); app.Lifetime.ApplicationStarted.Register(() => Utilities.Log(LogLevel.INFO, $"Webserver listening on {Config.HTTP.Host}:{Config.HTTP.Port}"));
// Register routes // Register routes
Routes.RegisterRoutes(app); Routes.RegisterRoutes(app);

View file

@ -7,4 +7,5 @@ public class RegisterResponse
public bool? verificationRequired { get; set; } = null; public bool? verificationRequired { get; set; } = null;
public string? username { get; set; } public string? username { get; set; }
public string? email { get; set; } public string? email { get; set; }
public string? sessionToken { get; set; }
} }

View file

@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Isopoh.Cryptography.Argon2; using Isopoh.Cryptography.Argon2;
using Microsoft.AspNetCore.Http.HttpResults;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
@ -12,6 +14,415 @@ public static class Routes
app.MapGet("/api/v1/info", HandleInfo); app.MapGet("/api/v1/info", HandleInfo);
app.MapPost("/api/v1/register", (Delegate) HandleRegister); app.MapPost("/api/v1/register", (Delegate) HandleRegister);
app.MapPost("/api/v1/verify", (Delegate) HandleVerify); 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<IResult> 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<UpdatePayload>();
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<IResult> 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<LogoutPayload>();
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<IResult> 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<SessionPayload>();
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<IResult> 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<JoinPayload>();
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<IResult> 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<LoginPayload>();
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<IResult> HandleVerify(HttpContext context) private static async Task<IResult> HandleVerify(HttpContext context)
@ -20,7 +431,7 @@ public static class Routes
if (context.Request.ContentType != "application/json") if (context.Request.ContentType != "application/json")
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = 400;
return Results.Json(new RegisterResponse return Results.Json(new VerifyResponse
{ {
success = false, success = false,
error = "Invalid request body" error = "Invalid request body"
@ -32,7 +443,7 @@ public static class Routes
string.IsNullOrWhiteSpace(payload.password) || string.IsNullOrWhiteSpace(payload.password)) string.IsNullOrWhiteSpace(payload.password) || string.IsNullOrWhiteSpace(payload.password))
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = 400;
return Results.Json(new RegisterResponse return Results.Json(new VerifyResponse
{ {
success = false, success = false,
error = "Invalid request body" error = "Invalid request body"
@ -43,7 +454,7 @@ public static class Routes
if (user == null || !Argon2.Verify(user.Password, payload.password)) if (user == null || !Argon2.Verify(user.Password, payload.password))
{ {
context.Response.StatusCode = 403; context.Response.StatusCode = 403;
return Results.Json(new RegisterResponse return Results.Json(new VerifyResponse
{ {
success = false, success = false,
error = "Invalid username or password" error = "Invalid username or password"
@ -53,7 +464,7 @@ public static class Routes
if (user.EmailVerified) if (user.EmailVerified)
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = 400;
return Results.Json(new RegisterResponse return Results.Json(new VerifyResponse
{ {
success = false, success = false,
error = "Account is already verified" error = "Account is already verified"
@ -63,7 +474,7 @@ public static class Routes
if (user.EmailVerificationCode != payload.code) if (user.EmailVerificationCode != payload.code)
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = 400;
return Results.Json(new RegisterResponse return Results.Json(new VerifyResponse
{ {
success = false, success = false,
error = "Invalid verification code" error = "Invalid verification code"
@ -71,14 +482,30 @@ public static class Routes
} }
// Verify the account // Verify the account
await Program.Database.SetUserVerified(payload.username, true); 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); }, Utilities.JsonSerializerOptions);
} }
private static async Task<IResult> HandleRegister(HttpContext context) private static async Task<IResult> HandleRegister(HttpContext context)
{ {
var ip = Utilities.GetIP(context);
if (ip == null)
{
context.Response.StatusCode = 403;
return Results.Empty;
}
// Check payload // Check payload
if (context.Request.ContentType != "application/json") if (context.Request.ContentType != "application/json")
{ {
@ -112,7 +539,7 @@ public static class Routes
}, Utilities.JsonSerializerOptions); }, Utilities.JsonSerializerOptions);
} }
var result = var result =
await Program.hCaptcha!.Verify(payload.captchaToken, context.Connection.RemoteIpAddress!.ToString()); await Program.hCaptcha!.Verify(payload.captchaToken, ip.ToString());
if (!result.success) if (!result.success)
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = 400;
@ -198,7 +625,7 @@ public static class Routes
if (Program.Config.Registration.EmailVerificationRequired) if (Program.Config.Registration.EmailVerificationRequired)
{ {
var code = Program.Random.Next(10000000, 99999999).ToString(); 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); await Program.Mailer.SendVerificationCode(payload.username, payload.email, code);
return Results.Json(new RegisterResponse return Results.Json(new RegisterResponse
{ {
@ -211,12 +638,15 @@ public static class Routes
else else
{ {
await Program.Database.RegisterAccount(payload.username, payload.email, payload.password, true, null); 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 return Results.Json(new RegisterResponse
{ {
success = true, success = true,
verificationRequired = false, verificationRequired = false,
email = payload.email, email = payload.email,
username = payload.username username = payload.username,
sessionToken = token
}, Utilities.JsonSerializerOptions); }, Utilities.JsonSerializerOptions);
} }
} }

View file

@ -1,9 +1,12 @@
using System.Net;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
public class Session public class Session
{ {
public string Token { get; set; } public string Token { get; set; }
public uint UserId { get; set; } public string Username { get; set; }
public DateTime Created { get; set; } public DateTime Created { get; set; }
public DateTime LastUsed { get; set; } public DateTime LastUsed { get; set; }
public IPAddress LastIP { get; set; }
} }

View file

@ -0,0 +1,6 @@
namespace Computernewb.CollabVMAuthServer;
public class SessionPayload
{
public string token { get; set; }
}

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
using System.Net;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
public class User public class User
@ -10,6 +12,7 @@ public class User
public string EmailVerificationCode { get; set; } public string EmailVerificationCode { get; set; }
public Rank Rank { get; set; } public Rank Rank { get; set; }
public bool Banned { get; set; } public bool Banned { get; set; }
public IPAddress RegistrationIP { get; set; }
} }
public enum Rank : uint public enum Rank : uint

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -84,4 +85,39 @@ public static class Utilities
new Regex("[!@#$%^&*()\\-_=+\\\\|\\[\\];:'\\\",<.>/?`~]").IsMatch(password) && new Regex("[!@#$%^&*()\\-_=+\\\\|\\[\\];:'\\\",<.>/?`~]").IsMatch(password) &&
new Regex("[0-9]").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;
}
} }

View file

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