Compare commits

...

10 Commits

Author SHA1 Message Date
Elijah R
a7df221777 Update readme 2025-05-06 04:53:20 -04:00
Elijah R
ce847c2207 bump version 2025-05-06 04:44:39 -04:00
Elijah R
290a9a5777 major refactor:
- Now uses EntityFrameworkCore for database operations
- HTTP handlers have all been refactored to use ASP.NET MVC controllers, and generally to be more idiomatic and remove copied boilerplate ugliness
- Authentication/Authorization refactored to use native ASP.NET core handlers
- Switch to Microsoft.Extensions.Logging instead of handrolled logging method
2025-05-06 04:34:46 -04:00
Elijah R
4b43dd833b Add launch.json and tasks.json 2025-05-05 18:08:42 -04:00
Elijah R
2d687c1440 bump dependencies, switch framework base 2025-05-05 17:51:31 -04:00
Elijah R
1a3224ab0b avoid unneccessary IPAddress.Parse call 2025-01-19 16:41:39 -05:00
mallory
e5c136a227 allow for searching by IP 2025-01-19 16:15:24 -05:00
mallory
4f17c1e58f allow for searching by IP 2025-01-17 22:06:58 -05:00
Elijah R
1ab7dd0626 Allow bots to use admin endpoints 2024-06-08 18:59:34 -04:00
Elijah R
c7f3cb3441 add unverified account and session expiry 2024-05-04 19:55:33 -04:00
74 changed files with 3355 additions and 2727 deletions

26
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/CollabVMAuthServer/bin/Debug/net8.0/CollabVMAuthServer.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/CollabVMAuthServer/CollabVMAuthServer.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/CollabVMAuthServer/CollabVMAuthServer.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/CollabVMAuthServer/CollabVMAuthServer.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -0,0 +1,26 @@
using System.CommandLine;
using System.CommandLine.Binding;
namespace Computernewb.CollabVMAuthServer;
public class AuthServerContext {
public required IConfig Config { get; set; }
}
public class AuthServerCliOptionsBinder : BinderBase<AuthServerContext> {
private readonly Option<string> _configPathOption;
public AuthServerCliOptionsBinder(Option<string> configPathOption) {
this._configPathOption = configPathOption;
}
protected override AuthServerContext GetBoundValue(BindingContext bindingContext)
{
var configPath = bindingContext.ParseResult.GetValueForOption(_configPathOption)!;
// Load config file
var config = IConfig.Load(configPath);
return new AuthServerContext {
Config = config,
};
}
}

View File

@@ -1,11 +0,0 @@
namespace Computernewb.CollabVMAuthServer;
public class Bot
{
public uint Id { get; set; }
public string Username { get; set; }
public string Token { get; set; }
public Rank Rank { get; set; }
public string Owner { get; set; }
public DateTime Created { get; set; }
}

View File

@@ -1,21 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>false</PublishAot> <PublishAot>false</PublishAot>
<RootNamespace>Computernewb.CollabVMAuthServer</RootNamespace> <RootNamespace>Computernewb.CollabVMAuthServer</RootNamespace>
<Company>Computernewb Development Team</Company> <Company>Computernewb Development Team</Company>
<AssemblyVersion>1.1</AssemblyVersion> <AssemblyVersion>2.0</AssemblyVersion>
<OutputType>Exe</OutputType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" /> <PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="MailKit" Version="4.4.0" /> <PackageReference Include="MailKit" Version="4.12.0" />
<PackageReference Include="MySqlConnector" Version="2.3.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
<PackageReference Include="Samboy063.Tomlet" Version="5.3.1" /> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Samboy063.Tomlet" Version="6.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace Computernewb.CollabVMAuthServer;
public class Cron
{
private readonly DbContextOptions<CollabVMAuthDbContext> _dbContextOptions;
private readonly ILogger _logger;
public Cron(DbContextOptions<CollabVMAuthDbContext> dbContextOptions) {
this._dbContextOptions = dbContextOptions;
this._logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger<Cron>();
}
private Timer timer = new Timer();
public async Task Start()
{
#if DEBUG
timer.Interval = 1000 * 60; // 60 seconds
#else
timer.Interval = 1000 * 60 * 10; // 10 minutes
#endif
timer.Elapsed += async (sender, e) => await RunAll();
await RunAll();
timer.Start();
}
public void Stop()
{
timer.Stop();
timer.Interval = 1000 * 60 * 10;
}
public async Task RunAll()
{
_logger.LogDebug("Running all cron jobs");
var t = new List<Task>();
t.Add(PurgeOldSessions());
if (Program.Config.Registration!.EmailVerificationRequired) t.Add(ExpireAccounts());
await Task.WhenAll(t);
_logger.LogDebug("Finished running all cron jobs");
}
// Expire unverified accounts after 2 days. Don't purge if the code is null
public async Task ExpireAccounts()
{
_logger.LogDebug("Purging unverified accounts");
using var dbContext = new CollabVMAuthDbContext(_dbContextOptions);
dbContext.Users.RemoveRange(dbContext.Users.Where(u => (!u.EmailVerified) && u.EmailVerificationCode != null && (u.Created < DateTime.UtcNow.AddDays(-2))));
var a = await dbContext.SaveChangesAsync();
_logger.LogInformation("Purged {a} unverified accounts", a);
}
public async Task PurgeOldSessions()
{
_logger.LogDebug("Purging old sessions");
using var dbContext = new CollabVMAuthDbContext(_dbContextOptions);
dbContext.Sessions.RemoveRange(dbContext.Sessions.Where(s => s.LastUsed < DateTime.UtcNow - TimeSpan.FromDays(Program.Config.Accounts!.SessionExpiryDays)));
var a = await dbContext.SaveChangesAsync();
_logger.LogInformation("Purged {a} old sessions", a);
}
}

View File

@@ -1,550 +0,0 @@
using System.Data;
using System.Net;
using Isopoh.Cryptography.Argon2;
using MySqlConnector;
namespace Computernewb.CollabVMAuthServer;
public class Database
{
private readonly string connectionString;
public Database(MySQLConfig config)
{
connectionString = new MySqlConnectionStringBuilder
{
Server = config.Host,
UserID = config.Username,
Password = config.Password,
Database = config.Database
}.ToString();
}
public async Task Init()
{
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(20) NOT NULL UNIQUE KEY,
password TEXT NOT NULL,
email TEXT NOT NULL UNIQUE KEY,
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,
ban_reason TEXT DEFAULT NULL,
registration_ip VARBINARY(16) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
developer BOOLEAN NOT NULL DEFAULT 0
);
""";
await cmd.ExecuteNonQueryAsync();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS sessions (
token CHAR(32) NOT NULL PRIMARY KEY,
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
)
""";
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();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS bots (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(20) NOT NULL UNIQUE KEY,
token CHAR(64) NOT NULL UNIQUE KEY,
cvm_rank INT UNSIGNED NOT NULL DEFAULT 1,
owner VARCHAR(20) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner) REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE
)
""";
await cmd.ExecuteNonQueryAsync();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS meta (
setting VARCHAR(20) NOT NULL PRIMARY KEY,
val TEXT NOT NULL
)
""";
await cmd.ExecuteNonQueryAsync();
}
public async Task<User?> GetUser(string? username = null, string? email = null)
{
if (username == null && email == null)
throw new ArgumentException("username or email must be provided");
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
if (username != null)
{
cmd.CommandText = "SELECT * FROM users WHERE username = @username";
cmd.Parameters.AddWithValue("@username", username);
}
else if (email != null)
{
cmd.CommandText = "SELECT * FROM users WHERE email = @email";
cmd.Parameters.AddWithValue("@email", email);
}
await using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return new User
{
Id = reader.GetUInt32("id"),
Username = reader.GetString("username"),
Password = reader.GetString("password"),
Email = reader.GetString("email"),
DateOfBirth = reader.GetDateOnly("date_of_birth"),
EmailVerified = reader.GetBoolean("email_verified"),
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"),
BanReason = reader.IsDBNull("ban_reason") ? null : reader.GetString("ban_reason"),
RegistrationIP = new IPAddress(reader.GetFieldValue<byte[]>("registration_ip")),
Joined = reader.GetDateTime("created"),
Developer = reader.GetBoolean("developer")
};
}
public async Task RegisterAccount(string username, string email, DateOnly dateOfBirth, string password, bool verified, IPAddress ip,
string? verificationcode = null)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = """
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)
""";
cmd.Parameters.AddWithValue("@username", username);
cmd.Parameters.AddWithValue("@password", Argon2.Hash(password));
cmd.Parameters.AddWithValue("@email", email);
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());
await cmd.ExecuteNonQueryAsync();
}
public async Task SetUserVerified(string username, bool verified)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "UPDATE users SET email_verified = @verified WHERE username = @username";
cmd.Parameters.AddWithValue("@verified", verified);
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<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(reader.GetFieldValue<byte[]>("last_ip"))
});
}
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(reader.GetFieldValue<byte[]>("last_ip"))
};
}
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 = null, string? newPassword = null, string? newEmail = null, int? newRank = null, bool? developer = null)
{
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);
}
if (newRank != null)
{
updates.Add("cvm_rank = @newRank");
cmd.Parameters.AddWithValue("@newRank", newRank);
}
if (developer != null)
{
updates.Add("developer = @developer");
cmd.Parameters.AddWithValue("@developer", developer);
}
cmd.CommandText = $"UPDATE users SET {string.Join(", ", updates)} WHERE username = @username";
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();
}
public async Task<User[]> ListUsers(string? filterUsername = null, string orderBy = "id", bool descending = false)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
var where = new List<string>();
if (filterUsername != null)
{
where.Add("username LIKE @filterUsername");
cmd.Parameters.AddWithValue("@filterUsername", filterUsername);
}
cmd.CommandText = $"SELECT * FROM users {(where.Count > 0 ? "WHERE" : "")} {string.Join(" AND ", where)} ORDER BY {orderBy} {(descending ? "DESC" : "ASC")}";
await using var reader = await cmd.ExecuteReaderAsync();
var users = new List<User>();
while (await reader.ReadAsync())
{
users.Add(new User
{
Id = reader.GetUInt32("id"),
Username = reader.GetString("username"),
Password = reader.GetString("password"),
Email = reader.GetString("email"),
DateOfBirth = reader.GetDateOnly("date_of_birth"),
EmailVerified = reader.GetBoolean("email_verified"),
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"),
BanReason = reader.IsDBNull("ban_reason") ? null : reader.GetString("ban_reason"),
RegistrationIP = new IPAddress(reader.GetFieldValue<byte[]>("registration_ip")),
Joined = reader.GetDateTime("created"),
Developer = reader.GetBoolean("developer")
});
}
return users.ToArray();
}
public async Task CreateBot(string username, string token, string owner)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "INSERT INTO bots (username, token, owner) VALUES (@username, @token, @owner)";
cmd.Parameters.AddWithValue("@username", username);
cmd.Parameters.AddWithValue("@token", token);
cmd.Parameters.AddWithValue("@owner", owner);
await cmd.ExecuteNonQueryAsync();
}
public async Task<Bot[]> ListBots(string? owner = null)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
var where = new List<string>();
if (owner != null)
{
where.Add("owner = @owner");
cmd.Parameters.AddWithValue("@owner", owner);
}
cmd.CommandText = $"SELECT * FROM bots {(where.Count > 0 ? "WHERE" : "")} {string.Join(" AND ", where)}";
await using var reader = await cmd.ExecuteReaderAsync();
var bots = new List<Bot>();
while (await reader.ReadAsync())
{
bots.Add(new Bot
{
Id = reader.GetUInt32("id"),
Username = reader.GetString("username"),
Token = reader.GetString("token"),
Rank = (Rank)reader.GetUInt32("cvm_rank"),
Owner = reader.GetString("owner"),
Created = reader.GetDateTime("created")
});
}
return bots.ToArray();
}
public async Task UpdateBot(string username, string? newUsername = null, string? newToken = null, int? newRank = null)
{
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 = @username");
cmd.Parameters.AddWithValue("@username", newUsername);
}
if (newToken != null)
{
updates.Add("token = @token");
cmd.Parameters.AddWithValue("@token", newToken);
}
if (newRank != null)
{
updates.Add("cvm_rank = @rank");
cmd.Parameters.AddWithValue("@rank", newRank);
}
cmd.CommandText = $"UPDATE bots SET {string.Join(", ", updates)} WHERE username = @username";
cmd.Parameters.AddWithValue("@username", username);
await cmd.ExecuteNonQueryAsync();
}
public async Task DeleteBots(string owner)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "DELETE FROM bots WHERE owner = @owner";
cmd.Parameters.AddWithValue("@owner", owner);
await cmd.ExecuteNonQueryAsync();
}
public async Task<Bot?> GetBot(string? username = null, string? token = null)
{
if (username == null && token == null)
throw new ArgumentException("username or token must be provided");
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
if (username != null)
{
cmd.CommandText = "SELECT * FROM bots WHERE username = @username";
cmd.Parameters.AddWithValue("@username", username);
}
else if (token != null)
{
cmd.CommandText = "SELECT * FROM bots WHERE token = @token";
cmd.Parameters.AddWithValue("@token", token);
}
await using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return new Bot
{
Id = reader.GetUInt32("id"),
Username = reader.GetString("username"),
Token = reader.GetString("token"),
Rank = (Rank)reader.GetUInt32("cvm_rank"),
Owner = reader.GetString("owner"),
Created = reader.GetDateTime("created")
};
}
public async Task<int> GetDatabaseVersion()
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
// If `users` table doesn't exist, return -1. This is hacky but I don't know of a better way
cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users'";
if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0)
return -1;
// If `meta` table doesn't exist, assume version 0
cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'meta'";
if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0)
return 0;
cmd.CommandText = "SELECT val FROM meta WHERE setting = 'db_version'";
await using var reader = await cmd.ExecuteReaderAsync();
await reader.ReadAsync();
return int.Parse(reader.GetString("val"));
}
public async Task SetDatabaseVersion(int version)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "INSERT INTO meta (setting, val) VALUES ('db_version', @version) ON DUPLICATE KEY UPDATE val = @version";
cmd.Parameters.AddWithValue("@version", version.ToString());
await cmd.ExecuteNonQueryAsync();
}
public async Task ExecuteNonQuery(string query)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = query;
await cmd.ExecuteNonQueryAsync();
}
public async Task SetBanned(string username, bool banned, string? reason)
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "UPDATE users SET banned = @banned, ban_reason = @reason WHERE username = @username";
cmd.Parameters.AddWithValue("@banned", banned);
cmd.Parameters.AddWithValue("@reason", reason);
cmd.Parameters.AddWithValue("@username", username);
await cmd.ExecuteNonQueryAsync();
}
public async Task<long> CountUsers()
{
await using var db = new MySqlConnection(connectionString);
await db.OpenAsync();
await using var cmd = db.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM users";
return (long)await cmd.ExecuteScalarAsync();
}
}

View File

@@ -0,0 +1,174 @@
using Computernewb.CollabVMAuthServer.Database.Schema;
using Microsoft.EntityFrameworkCore;
namespace Computernewb.CollabVMAuthServer.Database;
public partial class CollabVMAuthDbContext : DbContext
{
#pragma warning disable CS8618
public CollabVMAuthDbContext(DbContextOptions<CollabVMAuthDbContext> options)
: base(options)
{
}
#pragma warning restore CS8618
public virtual DbSet<Bot> Bots { get; set; }
public virtual DbSet<IpBan> IpBans { get; set; }
public virtual DbSet<Session> Sessions { get; set; }
public virtual DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.UseCollation("utf8mb4_unicode_ci")
.HasCharSet("utf8mb4");
modelBuilder.Entity<Bot>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
entity.ToTable("bots");
entity.HasIndex(e => e.Owner, "owner");
entity.HasIndex(e => e.Token, "token").IsUnique();
entity.HasIndex(e => e.Username, "username").IsUnique();
entity.Property(e => e.Id)
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
entity.Property(e => e.Created)
.HasDefaultValueSql("current_timestamp()")
.HasColumnType("timestamp")
.HasColumnName("created");
entity.Property(e => e.CvmRank)
.HasDefaultValueSql("'1'")
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank");
entity.Property(e => e.Owner)
.HasColumnName("owner");
entity.Property(e => e.Token)
.HasMaxLength(64)
.IsFixedLength()
.HasColumnName("token");
entity.Property(e => e.Username)
.HasMaxLength(20)
.HasColumnName("username");
entity.HasOne(d => d.OwnerNavigation).WithMany(p => p.Bots)
.HasPrincipalKey(p => p.Id)
.HasForeignKey(d => d.Owner)
.HasConstraintName("owner");
});
modelBuilder.Entity<IpBan>(entity =>
{
entity.HasKey(e => e.Ip).HasName("PRIMARY");
entity.ToTable("ip_bans");
entity.Property(e => e.Ip)
.HasMaxLength(16)
.HasColumnName("ip");
entity.Property(e => e.BannedAt)
.HasDefaultValueSql("current_timestamp()")
.HasColumnType("timestamp")
.HasColumnName("banned_at");
entity.Property(e => e.BannedBy)
.HasMaxLength(20)
.HasColumnName("banned_by");
entity.Property(e => e.Reason)
.HasColumnType("text")
.HasColumnName("reason");
});
modelBuilder.Entity<Session>(entity =>
{
entity.HasKey(e => e.Token).HasName("PRIMARY");
entity.ToTable("sessions");
entity.HasIndex(e => e.UserId, "user");
entity.Property(e => e.Token)
.HasMaxLength(32)
.IsFixedLength()
.HasColumnName("token");
entity.Property(e => e.Created)
.HasDefaultValueSql("current_timestamp()")
.HasColumnType("timestamp")
.HasColumnName("created");
entity.Property(e => e.LastIp)
.HasMaxLength(16)
.HasColumnName("last_ip");
entity.Property(e => e.LastUsed)
.HasDefaultValueSql("current_timestamp()")
.HasColumnType("timestamp")
.HasColumnName("last_used");
entity.Property(e => e.UserId)
.HasColumnName("user");
entity.HasOne(d => d.UserNavigation).WithMany(p => p.Sessions)
.HasPrincipalKey(p => p.Id)
.HasForeignKey(d => d.UserId)
.HasConstraintName("user");
});
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
entity.ToTable("users");
entity.HasIndex(e => e.Email, "email").IsUnique();
entity.HasIndex(e => e.Username, "username").IsUnique();
entity.Property(e => e.Id)
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
entity.Property(e => e.BanReason)
.HasColumnType("text")
.HasColumnName("ban_reason");
entity.Property(e => e.Banned).HasColumnName("banned");
entity.Property(e => e.Created)
.HasDefaultValueSql("current_timestamp()")
.HasColumnType("timestamp")
.HasColumnName("created");
entity.Property(e => e.CvmRank)
.HasDefaultValueSql("'1'")
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank");
entity.Property(e => e.DateOfBirth).HasColumnName("date_of_birth");
entity.Property(e => e.Developer).HasColumnName("developer");
entity.Property(e => e.Email)
.HasColumnType("text")
.HasColumnName("email");
entity.Property(e => e.EmailVerificationCode)
.HasMaxLength(8)
.IsFixedLength()
.HasColumnName("email_verification_code");
entity.Property(e => e.EmailVerified).HasColumnName("email_verified");
entity.Property(e => e.Password)
.HasColumnType("text")
.HasColumnName("password");
entity.Property(e => e.PasswordResetCode)
.HasMaxLength(8)
.IsFixedLength()
.HasColumnName("password_reset_code");
entity.Property(e => e.RegistrationIp)
.HasMaxLength(16)
.HasColumnName("registration_ip");
entity.Property(e => e.Username)
.HasMaxLength(20)
.HasColumnName("username");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Computernewb.CollabVMAuthServer.Database;
public class DesignTimeCollabVMAuthDbContextFactory : IDesignTimeDbContextFactory<CollabVMAuthDbContext> {
public CollabVMAuthDbContext CreateDbContext(string[] args) {
return new CollabVMAuthDbContext(
new DbContextOptionsBuilder<CollabVMAuthDbContext>()
.UseMySql(MariaDbServerVersion.LatestSupportedServerVersion).Options
);
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Data;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Logging;
namespace Computernewb.CollabVMAuthServer.Database;
public static class LegacyDbMigrator {
/// <summary>
/// The initial database migration that a pre-EF database will already be compatible with
/// </summary>
public const string INITIAL_MIGRATION_NAME = "20250505224256_InitialDbModel";
/// <summary>
/// Checks if a database was initialized by the legacy pre-EF methods. If so, create the migrations table and manually add the initial migration
/// </summary>
public static async Task CheckAndMigrate(CollabVMAuthDbContext context) {
var logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger("Computernewb.CollabVMAuthServer.Database.LegacyDbMigrator");
// Check if initial migration is pending
if ((await context.Database.GetAppliedMigrationsAsync()).Contains(INITIAL_MIGRATION_NAME)) {
logger.LogDebug("Initial migration already applied, skipping legacy db check");
return;
}
var conn = context.Database.GetDbConnection();
// Ensure the connection is open
if (conn.State != ConnectionState.Open) {
logger.LogDebug("Opening DB connection");
await conn.OpenAsync();
}
// Create command
using var cmd = conn.CreateCommand();
// Check if meta table exists
cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'meta'";
// If meta table doesn't exist, db is uninitialized
if ((long)(await cmd.ExecuteScalarAsync() ?? 0) == 0) {
logger.LogDebug("Database is uninitialized");
return;
}
// Check database version
cmd.CommandText = "SELECT val FROM meta WHERE setting = 'db_version'";
var dbVer = (string?) await cmd.ExecuteScalarAsync();
if (dbVer != "1") {
// 1 was the only version ever used in the old format, so this should not happen
throw new InvalidOperationException($"Invalid database state, cannot automatically migrate (Expected DB version `1`, got `{dbVer}`)");
}
// Database can be migrated
logger.LogDebug("Legacy database schema detected. Automatically initializing migrations table");
// Manually create migrations table
var historyRepo = context.Database.GetService<IHistoryRepository>();
cmd.CommandText = historyRepo.GetCreateIfNotExistsScript();
logger.LogDebug("Migrations table create script: {cmd}", cmd.CommandText);
await cmd.ExecuteNonQueryAsync();
// Insert row for initial migration
cmd.CommandText = historyRepo.GetInsertScript(
new HistoryRow(
INITIAL_MIGRATION_NAME,
typeof(DbContext).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion
)
);
logger.LogDebug("Migrations table insert script: {cmd}", cmd.CommandText);
await cmd.ExecuteNonQueryAsync();
logger.LogInformation("Successfully initialized migrations table");
// Drop meta table
cmd.CommandText = "DROP TABLE meta;";
await cmd.ExecuteNonQueryAsync();
logger.LogInformation("Dropped legacy meta table");
}
}

View File

@@ -0,0 +1,275 @@
// <auto-generated />
using System;
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Computernewb.CollabVMAuthServer.Database.Migrations
{
[DbContext(typeof(CollabVMAuthDbContext))]
[Migration("20250505224256_InitialDbModel")]
partial class InitialDbModel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.UseCollation("utf8mb4_unicode_ci")
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4");
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("owner");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("char(64)")
.HasColumnName("token")
.IsFixedLength();
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Owner" }, "owner");
b.HasIndex(new[] { "Token" }, "token")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique();
b.ToTable("bots", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b =>
{
b.Property<byte[]>("Ip")
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("ip");
b.Property<DateTime>("BannedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("banned_at")
.HasDefaultValueSql("current_timestamp()");
b.Property<string>("BannedBy")
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("banned_by");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reason");
b.HasKey("Ip")
.HasName("PRIMARY");
b.ToTable("ip_bans", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.Property<string>("Token")
.HasMaxLength(32)
.HasColumnType("char(32)")
.HasColumnName("token")
.IsFixedLength();
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<byte[]>("LastIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("last_ip");
b.Property<DateTime>("LastUsed")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("last_used")
.HasDefaultValueSql("current_timestamp()");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Token")
.HasName("PRIMARY");
b.HasIndex(new[] { "Username" }, "username");
b.ToTable("sessions", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<string>("BanReason")
.HasColumnType("text")
.HasColumnName("ban_reason");
b.Property<bool>("Banned")
.HasColumnType("tinyint(1)")
.HasColumnName("banned");
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<bool>("Developer")
.HasColumnType("tinyint(1)")
.HasColumnName("developer");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("EmailVerificationCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("email_verification_code")
.IsFixedLength();
b.Property<bool>("EmailVerified")
.HasColumnType("tinyint(1)")
.HasColumnName("email_verified");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password");
b.Property<string>("PasswordResetCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("password_reset_code")
.IsFixedLength();
b.Property<byte[]>("RegistrationIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("registration_ip");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Email" }, "email")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation")
.WithMany("Bots")
.HasForeignKey("Owner")
.HasPrincipalKey("Username")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("bots_ibfk_1");
b.Navigation("OwnerNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UsernameNavigation")
.WithMany("Sessions")
.HasForeignKey("Username")
.HasPrincipalKey("Username")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("sessions_ibfk_1");
b.Navigation("UsernameNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Navigation("Bots");
b.Navigation("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,157 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Computernewb.CollabVMAuthServer.Database.Migrations
{
/// <inheritdoc />
public partial class InitialDbModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ip_bans",
columns: table => new
{
ip = table.Column<byte[]>(type: "varbinary(16)", maxLength: 16, nullable: false),
reason = table.Column<string>(type: "text", nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
banned_by = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
banned_at = table.Column<DateTime>(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()")
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.ip);
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<uint>(type: "int(10) unsigned", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
username = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
password = table.Column<string>(type: "text", nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
email = table.Column<string>(type: "text", nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
date_of_birth = table.Column<DateOnly>(type: "date", nullable: false),
email_verified = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
email_verification_code = table.Column<string>(type: "char(8)", fixedLength: true, maxLength: 8, nullable: true, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
password_reset_code = table.Column<string>(type: "char(8)", fixedLength: true, maxLength: 8, nullable: true, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
cvm_rank = table.Column<uint>(type: "int(10) unsigned", nullable: false, defaultValueSql: "'1'"),
banned = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
ban_reason = table.Column<string>(type: "text", nullable: true, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
registration_ip = table.Column<byte[]>(type: "varbinary(16)", maxLength: 16, nullable: false),
created = table.Column<DateTime>(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"),
developer = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.id);
table.UniqueConstraint("username", x => x.username);
table.UniqueConstraint("email", x => x.email);
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "bots",
columns: table => new
{
id = table.Column<uint>(type: "int(10) unsigned", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
username = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
token = table.Column<string>(type: "char(64)", fixedLength: true, maxLength: 64, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
cvm_rank = table.Column<uint>(type: "int(10) unsigned", nullable: false, defaultValueSql: "'1'"),
owner = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
created = table.Column<DateTime>(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()")
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.id);
table.UniqueConstraint("username", x => x.username);
table.UniqueConstraint("token", x => x.token);
table.ForeignKey(
name: "bots_ibfk_1",
column: x => x.owner,
principalTable: "users",
principalColumn: "username",
onUpdate: ReferentialAction.Cascade,
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "owner",
column: "owner",
table: "bots"
);
migrationBuilder.CreateTable(
name: "sessions",
columns: table => new
{
token = table.Column<string>(type: "char(32)", fixedLength: true, maxLength: 32, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
username = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
created = table.Column<DateTime>(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"),
last_used = table.Column<DateTime>(type: "timestamp", nullable: false, defaultValueSql: "current_timestamp()"),
last_ip = table.Column<byte[]>(type: "varbinary(16)", maxLength: 16, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.token);
table.ForeignKey(
name: "sessions_ibfk_1",
column: x => x.username,
principalTable: "users",
principalColumn: "username",
onUpdate: ReferentialAction.Cascade,
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "username",
column: "username",
table: "sessions"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "bots");
migrationBuilder.DropTable(
name: "ip_bans");
migrationBuilder.DropTable(
name: "sessions");
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@@ -0,0 +1,270 @@
// <auto-generated />
using System;
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Computernewb.CollabVMAuthServer.Database.Migrations
{
[DbContext(typeof(CollabVMAuthDbContext))]
[Migration("20250506060015_UseUserIdAsForeignKey")]
partial class UseUserIdAsForeignKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.UseCollation("utf8mb4_unicode_ci")
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4");
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<uint>("Owner")
.HasColumnType("int(10) unsigned")
.HasColumnName("owner");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("char(64)")
.HasColumnName("token")
.IsFixedLength();
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Owner" }, "owner");
b.HasIndex(new[] { "Token" }, "token")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique();
b.ToTable("bots", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b =>
{
b.Property<byte[]>("Ip")
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("ip");
b.Property<DateTime>("BannedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("banned_at")
.HasDefaultValueSql("current_timestamp()");
b.Property<string>("BannedBy")
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("banned_by");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reason");
b.HasKey("Ip")
.HasName("PRIMARY");
b.ToTable("ip_bans", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.Property<string>("Token")
.HasMaxLength(32)
.HasColumnType("char(32)")
.HasColumnName("token")
.IsFixedLength();
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<byte[]>("LastIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("last_ip");
b.Property<DateTime>("LastUsed")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("last_used")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("UserId")
.HasColumnType("int(10) unsigned")
.HasColumnName("user");
b.HasKey("Token")
.HasName("PRIMARY");
b.HasIndex(new[] { "UserId" }, "user");
b.ToTable("sessions", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<string>("BanReason")
.HasColumnType("text")
.HasColumnName("ban_reason");
b.Property<bool>("Banned")
.HasColumnType("tinyint(1)")
.HasColumnName("banned");
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<bool>("Developer")
.HasColumnType("tinyint(1)")
.HasColumnName("developer");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("EmailVerificationCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("email_verification_code")
.IsFixedLength();
b.Property<bool>("EmailVerified")
.HasColumnType("tinyint(1)")
.HasColumnName("email_verified");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password");
b.Property<string>("PasswordResetCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("password_reset_code")
.IsFixedLength();
b.Property<byte[]>("RegistrationIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("registration_ip");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Email" }, "email")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique()
.HasDatabaseName("username1");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation")
.WithMany("Bots")
.HasForeignKey("Owner")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("owner");
b.Navigation("OwnerNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UserNavigation")
.WithMany("Sessions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("user");
b.Navigation("UserNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Navigation("Bots");
b.Navigation("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Computernewb.CollabVMAuthServer.Database.Migrations
{
/// <inheritdoc />
public partial class UseUserIdAsForeignKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "sessions_ibfk_1",
table: "sessions");
migrationBuilder.DropForeignKey(
name: "bots_ibfk_1",
table: "bots");
migrationBuilder.DropIndex(
name: "username",
table: "sessions");
migrationBuilder.DropIndex(
name: "owner",
table: "bots"
);
migrationBuilder.RenameColumn(
table: "bots",
name: "owner",
newName: "owner_tmp");
migrationBuilder.AddColumn<uint>(
name: "user",
table: "sessions",
type: "int(10) unsigned",
nullable: false);
migrationBuilder.AddColumn<uint>(
name: "owner",
table: "bots",
type: "int(10) unsigned",
nullable: false);
migrationBuilder.CreateIndex(
name: "user",
table: "sessions",
column: "user");
migrationBuilder.CreateIndex(
name: "owner",
table: "bots",
column: "owner");
// Migrate data
migrationBuilder.Sql(
"""
UPDATE sessions INNER JOIN users ON sessions.username=users.username
SET sessions.user = users.id;
"""
);
migrationBuilder.Sql(
"""
UPDATE bots INNER JOIN users ON bots.owner_tmp=users.username
SET bots.owner = users.id;
"""
);
migrationBuilder.AddForeignKey(
name: "owner",
table: "bots",
column: "owner",
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "user",
table: "sessions",
column: "user",
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.DropColumn(
name: "username",
table: "sessions");
migrationBuilder.DropColumn(
name: "owner_tmp",
table: "bots");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "owner",
table: "bots");
migrationBuilder.DropForeignKey(
name: "user",
table: "sessions");
migrationBuilder.DropIndex(
name: "user",
table: "sessions");
migrationBuilder.DropColumn(
name: "user",
table: "sessions");
migrationBuilder.RenameIndex(
name: "username1",
table: "users",
newName: "username2");
migrationBuilder.AddColumn<string>(
name: "username",
table: "sessions",
type: "varchar(20)",
maxLength: 20,
nullable: false,
defaultValue: "",
collation: "utf8mb4_unicode_ci")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "owner",
table: "bots",
type: "varchar(20)",
maxLength: 20,
nullable: false,
collation: "utf8mb4_unicode_ci",
oldClrType: typeof(uint),
oldType: "int(10) unsigned")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddUniqueConstraint(
name: "AK_users_username",
table: "users",
column: "username");
migrationBuilder.CreateIndex(
name: "username1",
table: "sessions",
column: "username");
migrationBuilder.AddForeignKey(
name: "bots_ibfk_1",
table: "bots",
column: "owner",
principalTable: "users",
principalColumn: "username",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "sessions_ibfk_1",
table: "sessions",
column: "username",
principalTable: "users",
principalColumn: "username",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -0,0 +1,267 @@
// <auto-generated />
using System;
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Computernewb.CollabVMAuthServer.Database.Migrations
{
[DbContext(typeof(CollabVMAuthDbContext))]
partial class CollabVMAuthDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.UseCollation("utf8mb4_unicode_ci")
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.HasCharSet(modelBuilder, "utf8mb4");
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<uint>("Owner")
.HasColumnType("int(10) unsigned")
.HasColumnName("owner");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("char(64)")
.HasColumnName("token")
.IsFixedLength();
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Owner" }, "owner");
b.HasIndex(new[] { "Token" }, "token")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique();
b.ToTable("bots", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.IpBan", b =>
{
b.Property<byte[]>("Ip")
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("ip");
b.Property<DateTime>("BannedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("banned_at")
.HasDefaultValueSql("current_timestamp()");
b.Property<string>("BannedBy")
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("banned_by");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reason");
b.HasKey("Ip")
.HasName("PRIMARY");
b.ToTable("ip_bans", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.Property<string>("Token")
.HasMaxLength(32)
.HasColumnType("char(32)")
.HasColumnName("token")
.IsFixedLength();
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<byte[]>("LastIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("last_ip");
b.Property<DateTime>("LastUsed")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("last_used")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("UserId")
.HasColumnType("int(10) unsigned")
.HasColumnName("user");
b.HasKey("Token")
.HasName("PRIMARY");
b.HasIndex(new[] { "UserId" }, "user");
b.ToTable("sessions", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Property<uint>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("id");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<uint>("Id"));
b.Property<string>("BanReason")
.HasColumnType("text")
.HasColumnName("ban_reason");
b.Property<bool>("Banned")
.HasColumnType("tinyint(1)")
.HasColumnName("banned");
b.Property<DateTime>("Created")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp")
.HasColumnName("created")
.HasDefaultValueSql("current_timestamp()");
b.Property<uint>("CvmRank")
.ValueGeneratedOnAdd()
.HasColumnType("int(10) unsigned")
.HasColumnName("cvm_rank")
.HasDefaultValueSql("'1'");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<bool>("Developer")
.HasColumnType("tinyint(1)")
.HasColumnName("developer");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("EmailVerificationCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("email_verification_code")
.IsFixedLength();
b.Property<bool>("EmailVerified")
.HasColumnType("tinyint(1)")
.HasColumnName("email_verified");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password");
b.Property<string>("PasswordResetCode")
.HasMaxLength(8)
.HasColumnType("char(8)")
.HasColumnName("password_reset_code")
.IsFixedLength();
b.Property<byte[]>("RegistrationIp")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varbinary(16)")
.HasColumnName("registration_ip");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "Email" }, "email")
.IsUnique();
b.HasIndex(new[] { "Username" }, "username")
.IsUnique()
.HasDatabaseName("username1");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Bot", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "OwnerNavigation")
.WithMany("Bots")
.HasForeignKey("Owner")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("bots_ibfk_1");
b.Navigation("OwnerNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.Session", b =>
{
b.HasOne("Computernewb.CollabVMAuthServer.Database.Schema.User", "UserNavigation")
.WithMany("Sessions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("sessions_ibfk_1");
b.Navigation("UserNavigation");
});
modelBuilder.Entity("Computernewb.CollabVMAuthServer.Database.Schema.User", b =>
{
b.Navigation("Bots");
b.Navigation("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Computernewb.CollabVMAuthServer.Database.Schema;
public partial class Bot
{
public uint Id { get; set; }
public string Username { get; set; } = null!;
public string Token { get; set; } = null!;
public uint CvmRank { get; set; }
public uint Owner { get; set; }
public DateTime Created { get; set; }
public virtual User OwnerNavigation { get; set; } = null!;
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace Computernewb.CollabVMAuthServer.Database.Schema;
public partial class IpBan
{
public byte[] Ip { get; set; } = null!;
public string Reason { get; set; } = null!;
public string? BannedBy { get; set; }
public DateTime BannedAt { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Computernewb.CollabVMAuthServer.Database.Schema;
public enum Rank : uint {
Registered = 1,
Admin = 2,
Moderator = 3
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace Computernewb.CollabVMAuthServer.Database.Schema;
public partial class Session
{
public string Token { get; set; } = null!;
public uint UserId { get; set; }
public DateTime Created { get; set; }
public DateTime LastUsed { get; set; }
public byte[] LastIp { get; set; } = null!;
public virtual User UserNavigation { get; set; } = null!;
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace Computernewb.CollabVMAuthServer.Database.Schema;
public partial class User
{
public uint Id { get; set; }
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public string Email { get; set; } = null!;
public DateOnly DateOfBirth { get; set; }
public bool EmailVerified { get; set; }
public string? EmailVerificationCode { get; set; }
public string? PasswordResetCode { get; set; }
public uint CvmRank { get; set; }
public bool Banned { get; set; }
public string? BanReason { get; set; }
public byte[] RegistrationIp { get; set; } = null!;
public DateTime Created { get; set; }
public bool Developer { get; set; }
public virtual ICollection<Bot> Bots { get; set; } = new List<Bot>();
public virtual ICollection<Session> Sessions { get; set; } = new List<Session>();
}

View File

@@ -1,33 +0,0 @@
using System.Collections.ObjectModel;
namespace Computernewb.CollabVMAuthServer;
public static class DatabaseUpdate
{
public const int CurrentVersion = 1;
private static ReadOnlyDictionary<int, Func<Database, Task>> Updates = new Dictionary<int, Func<Database, Task>>()
{
{ 1, async db =>
{
// Update to version 1
// Add ban_reason column to users table
await db.ExecuteNonQuery("ALTER TABLE users ADD COLUMN ban_reason TEXT DEFAULT NULL");
}},
}.AsReadOnly();
public async static Task Update(Database db)
{
var version = await db.GetDatabaseVersion();
if (version == -1) throw new InvalidOperationException("Uninitialized database cannot be updated");
if (version == CurrentVersion) return;
if (version > CurrentVersion) throw new InvalidOperationException("Database version is newer than the server supports");
Utilities.Log(LogLevel.INFO, $"Updating database from version {version} to {CurrentVersion}");
for (int i = version + 1; i <= CurrentVersion; i++)
{
if (!Updates.TryGetValue(i, out var update)) throw new InvalidOperationException($"No update available for version {i}");
await update(db);
}
await db.SetDatabaseVersion(CurrentVersion);
}
}

View File

@@ -1,494 +0,0 @@
using System.Net;
using System.Text.Json;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
namespace Computernewb.CollabVMAuthServer.HTTP;
public static class AdminRoutes
{
public static void RegisterRoutes(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/admin/users", (Delegate)HandleAdminUsers);
app.MapPost("/api/v1/admin/updateuser", (Delegate)HandleAdminUpdateUser);
app.MapPost("/api/v1/admin/updatebot", (Delegate)HandleAdminUpdateBot);
app.MapPost("/api/v1/admin/ban", (Delegate)HandleBanUser);
app.MapPost("/api/v1/admin/ipban", (Delegate)HandleIPBan);
}
private static async Task<IResult> HandleIPBan(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new IPBanResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
IPBanPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<IPBanPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new IPBanResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.session) || string.IsNullOrWhiteSpace(payload.ip) || (payload.banned && string.IsNullOrWhiteSpace(payload.reason)) || payload.banned == null || !IPAddress.TryParse(payload.ip, out var ip))
{
context.Response.StatusCode = 400;
return Results.Json(new IPBanResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.session);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new IPBanResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check rank
var user = await Program.Database.GetUser(session.Username)
?? throw new Exception("Could not lookup user from session");
if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new IPBanResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Set ban
if (payload.banned)
{
await Program.Database.BanIP(ip, payload.reason, user.Username);
}
else
{
await Program.Database.UnbanIP(ip);
}
return Results.Json(new IPBanResponse
{
success = true
}, Utilities.JsonSerializerOptions);
}
private static async Task<IResult> HandleBanUser(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new BanUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
BanUserPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<BanUserPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new BanUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username) || (payload.banned && string.IsNullOrWhiteSpace(payload.reason)) || payload.banned == null)
{
context.Response.StatusCode = 400;
return Results.Json(new BanUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new BanUserResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check rank
var user = await Program.Database.GetUser(session.Username)
?? throw new Exception("Could not lookup user from session");
if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new BanUserResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Check target user
var targetUser = await Program.Database.GetUser(payload.username);
if (targetUser == null)
{
context.Response.StatusCode = 400;
return Results.Json(new BanUserResponse
{
success = false,
error = "User not found"
}, Utilities.JsonSerializerOptions);
}
// Set ban
await Program.Database.SetBanned(targetUser.Username, payload.banned, payload.banned ? payload.reason : null);
return Results.Json(new BanUserResponse
{
success = true
}, Utilities.JsonSerializerOptions);
}
private static async Task<IResult> HandleAdminUpdateBot(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
AdminUpdateBotPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<AdminUpdateBotPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check rank
var user = await Program.Database.GetUser(session.Username)
?? throw new Exception("Could not lookup user from session");
if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Check target bot
var targetBot = await Program.Database.GetBot(payload.username);
if (targetBot == null)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Bot not found"
}, Utilities.JsonSerializerOptions);
}
// Make sure at least one field is being updated
if (payload.rank == null)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "No fields to update"
}, Utilities.JsonSerializerOptions);
}
// Moderators cannot promote bots to admin, and can only promote their own bots to moderator
else if ((Rank)payload.rank == Rank.Admin && user.Rank == Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
if (targetBot.Owner != user.Username && user.Rank == Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Check rank
int? rank = payload.rank;
if (rank != null && rank < 1 || rank > 3)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateBotResponse
{
success = false,
error = "Invalid rank"
}, Utilities.JsonSerializerOptions);
}
// Update rank
await Program.Database.UpdateBot(targetBot.Username, newRank: payload.rank);
return Results.Json(new AdminUpdateBotResponse
{
success = true
}, Utilities.JsonSerializerOptions);
}
private static async Task<IResult> HandleAdminUpdateUser(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
AdminUpdateUserPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<AdminUpdateUserPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check rank
var user = await Program.Database.GetUser(session.Username)
?? throw new Exception("Could not lookup user from session");
if (user.Rank != Rank.Admin)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Check target user
var targetUser = await Program.Database.GetUser(payload.username);
if (targetUser == null)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "User not found"
}, Utilities.JsonSerializerOptions);
}
// Check rank
int? rank = payload.rank;
if (rank != null && rank < 1 || rank > 3)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Invalid rank"
}, Utilities.JsonSerializerOptions);
}
// Moderators cannot change ranks
if (user.Rank == Rank.Moderator && rank != null)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUpdateUserResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Check developer
bool? developer = payload.developer;
// Update rank
await Program.Database.UpdateUser(targetUser.Username, newRank: payload.rank, developer: developer);
if (developer == false)
{
await Program.Database.DeleteBots(targetUser.Username);
}
return Results.Json(new AdminUpdateUserResponse
{
success = true
}, Utilities.JsonSerializerOptions);
}
private static async Task<IResult> HandleAdminUsers(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
AdminUsersPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<AdminUsersPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || payload.page < 1 || payload.resultsPerPage < 1)
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check rank
var user = await Program.Database.GetUser(session.Username)
?? throw new Exception("Could not lookup user from session");
if (user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Validate orderBy
if (payload.orderBy != null && !new string[] { "id", "username", "email", "date_of_birth", "cvm_rank", "banned", "created" }.Contains(payload.orderBy))
{
context.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid orderBy"
}, Utilities.JsonSerializerOptions);
}
// Get users
string? filterUsername = null;
if (payload.filterUsername != null)
{
filterUsername = "%" + payload.filterUsername
.Replace("%", "!%")
.Replace("!", "!!")
.Replace("_", "!_")
.Replace("[", "![") + "%";
}
var users = (await Program.Database.ListUsers(filterUsername, payload.orderBy ?? "id", payload.orderByDescending)).Select(user => new AdminUser
{
id = user.Id,
username = user.Username,
email = user.Email,
rank = (int)user.Rank,
banned = user.Banned,
banReason = user.BanReason ?? "",
dateOfBirth = user.DateOfBirth.ToString("yyyy-MM-dd"),
dateJoined = user.Joined.ToString("yyyy-MM-dd HH:mm:ss"),
registrationIp = user.RegistrationIP.ToString(),
developer = user.Developer
}).ToArray();
var page = users.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage).ToArray();
return Results.Json(new AdminUsersResponse
{
success = true,
users = page,
totalPageCount = (int)Math.Ceiling(users.Length / (double)payload.resultsPerPage)
}, Utilities.JsonSerializerOptions);
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Computernewb.CollabVMAuthServer.Database.Schema;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Computernewb.CollabVMAuthServer.HTTP;
public partial class CollabVMAuthenticationHandler : SignInAuthenticationHandler<CollabVMAuthenticationSchemeOptions>
{
public CollabVMAuthenticationHandler(IOptionsMonitor<CollabVMAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
{
}
[GeneratedRegex("^Session (?<token>.+)$")]
private static partial Regex AuthorizationHeaderRegex();
private string? GetSessionTokenFromAuthorizationHeader() {
// Check for Authorization header
var authorizationHeader = Context.Request.Headers.Authorization.ToString();
if (AuthorizationHeaderRegex().Match(authorizationHeader).Groups.TryGetValue("token", out var match)) {
return match.Value;
} else {
return null;
}
}
private string? GetSessionTokenFromCookie() {
if (Context.Request.Cookies.TryGetValue("collabvm_session", out var token)) {
return token;
} else {
return null;
}
}
private async Task<string?> GetSessionTokenFromBody() {
// This is how the current webapp sends the token.
// I really do not like this. Should be changed to use authorization or cookie and then we can eventually axe this
if (Context.Request.ContentType != "application/json") {
return null;
}
Context.Request.EnableBuffering();
var payload = await Context.Request.ReadFromJsonAsync<RequestBodyAuthenticationPayload>();
// sigh
Context.Request.Body.Position = 0;
// This can be two different keys because I was on crack cocaine when I wrote the original API
return
payload?.Session ??
payload?.Token;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// There are multiple ways the client can send a session token. We check them in order of preference
var sessionToken =
GetSessionTokenFromAuthorizationHeader() ??
GetSessionTokenFromCookie() ??
await GetSessionTokenFromBody();
// If no session token was provided, fail
if (sessionToken == null) {
return AuthenticateResult.NoResult();
}
// Open db and find session
using var dbContext = new CollabVMAuthDbContext(Options.DbContextOptions);
Claim[] claims = [];
if (sessionToken.Length == 32) { // User
var session = await dbContext.Sessions.Include(s => s.UserNavigation).FirstOrDefaultAsync(s => s.Token == sessionToken);
// Fail if invalid token or expired
if (session == null || DateTime.UtcNow > session.LastUsed.AddDays(Program.Config.Accounts!.SessionExpiryDays)) {
return AuthenticateResult.NoResult();
}
claims = [
new("type", "user"),
new("id", session.UserNavigation.Id.ToString()),
new("username", session.UserNavigation.Username),
new("rank", session.UserNavigation.CvmRank.ToString()),
new("developer", session.UserNavigation.Developer ? "1" : "0")
];
} else if (sessionToken.Length == 64) { // Bot
var bot = await dbContext.Bots.FirstOrDefaultAsync(b => b.Token == sessionToken);
// Fail if unknown bot
if (bot == null) {
return AuthenticateResult.NoResult();
}
claims = [
new("type", "bot"),
new("id", bot.Id.ToString()),
new("username", bot.Username),
new("rank", bot.CvmRank.ToString())
];
} else {
return AuthenticateResult.NoResult();
}
// Return success result
return AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(claims, Scheme.Name)
),
Scheme.Name
)
);
}
protected override async Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties)
{
var token = Utilities.RandomString(32);
using var dbContext = new CollabVMAuthDbContext(Options.DbContextOptions);
// Add to database
await dbContext.Sessions.AddAsync(new Session {
Token = token,
UserId = uint.Parse(user.FindFirstValue("id")
?? throw new InvalidOperationException("User ID claim was null")),
Created = DateTime.UtcNow,
LastUsed = DateTime.UtcNow,
LastIp = Context.Connection.RemoteIpAddress!.GetAddressBytes(),
});
await dbContext.SaveChangesAsync();
// Add claim
user.Identities.First().AddClaim(new("token", token));
// Set cookie
Context.Response.Cookies.Append("collabvm_session", token);
}
protected override Task HandleSignOutAsync(AuthenticationProperties? properties)
{
throw new System.NotImplementedException();
}
}

View File

@@ -0,0 +1,9 @@
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
namespace Computernewb.CollabVMAuthServer.HTTP;
public class CollabVMAuthenticationSchemeOptions : AuthenticationSchemeOptions {
public DbContextOptions<CollabVMAuthDbContext> DbContextOptions { get; set; } = new();
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
namespace Computernewb.CollabVMAuthServer.HTTP;
public class CollabVMAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
{
if (authorizeResult.Forbidden) {
context.Response.StatusCode = 403;
var requirement = authorizeResult.AuthorizationFailure!.FailedRequirements.First();
if (requirement is ClaimsAuthorizationRequirement req) {
if (req.ClaimType == "rank") {
await context.Response.WriteAsJsonAsync(new ApiResponse {
success = false,
error = "You do not have the correct rank to do that."
});
return;
} else if (req.ClaimType == "developer") {
await context.Response.WriteAsJsonAsync(new ApiResponse {
success = false,
error = "You must be a developer to do that."
});
return;
}
}
await context.Response.WriteAsJsonAsync(new ApiResponse {
success = false,
error = "Access forbidden."
});
return;
} else if (authorizeResult.Challenged) {
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new ApiResponse {
success = false,
error = "You need to login to do that."
});
return;
}
// Fall back to default handler
await defaultHandler.HandleAsync(next, context, policy, authorizeResult);
}
}

View File

@@ -0,0 +1,308 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Computernewb.CollabVMAuthServer.Database.Schema;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Computernewb.CollabVMAuthServer.HTTP.Controllers;
[Route("api/v1/admin")]
[ApiController]
public class AdminApiController : ControllerBase
{
private readonly CollabVMAuthDbContext _dbContext;
public AdminApiController(CollabVMAuthDbContext dbContext) {
this._dbContext = dbContext;
}
[HttpPost]
[Route("ipban")]
[Authorize("Staff")]
public async Task<IResult> HandleIPBan(IPBanPayload payload)
{
var ip = IPAddress.Parse(payload.ip).GetAddressBytes();
// Find or create ban
var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == ip);
if (payload.banned)
{
ban ??= new IpBan { Ip = ip };
ban.Reason = payload.reason;
ban.BannedBy = HttpContext.User.FindFirstValue("username");
ban.BannedAt = DateTime.UtcNow;
}
else
{
if (ban == null) {
return Results.Json(new ApiResponse {
success = false,
error = "IP is not banned."
}, statusCode: 400);
}
_dbContext.IpBans.Remove(ban);
}
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("ban")]
[Authorize("Staff")]
public async Task<IResult> HandleBanUser(BanUserPayload payload)
{
// Check target user
var targetUser = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username);
if (targetUser == null)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "User not found"
});
}
// Set ban
targetUser.Banned = payload.banned;
targetUser.BanReason = payload.reason;
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("updatebot")]
[Authorize("Staff")]
public async Task<IResult> HandleAdminUpdateBot(AdminUpdateBotPayload payload)
{
// Check target bot
var targetBot = await _dbContext.Bots.FirstOrDefaultAsync(b => b.Username == payload.username);
if (targetBot == null)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Bot not found"
});
}
// Make sure at least one field is being updated
if (payload.rank == null)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "No fields to update"
});
}
// Moderators cannot promote bots to admin, and can only promote their own bots to moderator
if ((Rank)payload.rank == Rank.Admin && HttpContext.User.FindFirstValue("rank") == "3")
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new ApiResponse
{
success = false,
error = "Insufficient permissions"
});
}
if (targetBot.Owner != uint.Parse(HttpContext.User.FindFirstValue("id")!) && HttpContext.User.FindFirstValue("rank") == "3")
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new ApiResponse
{
success = false,
error = "Insufficient permissions"
});
}
// Check rank
uint? rank = payload.rank;
if (rank != null) {
if (rank < 1 || rank > 3) {
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid rank"
});
}
targetBot.CvmRank = payload.rank.Value;
}
// Update
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("updateuser")]
[Authorize("Staff")]
public async Task<IResult> HandleAdminUpdateUser(AdminUpdateUserPayload payload)
{
// Check target user
var targetUser = await _dbContext.Users.Include(u => u.Bots).FirstOrDefaultAsync(u => u.Username == payload.username);
if (targetUser == null)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "User not found"
});
}
// Check rank
uint? rank = payload.rank;
if (rank != null) {
if (rank < 1 || rank > 3) {
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid rank"
});
}
// Moderators cannot change ranks
if (HttpContext.User.FindFirstValue("rank") == "3") {
HttpContext.Response.StatusCode = 403;
return Results.Json(new ApiResponse
{
success = false,
error = "Insufficient permissions"
});
}
targetUser.CvmRank = rank.Value;
}
// Check developer
if (payload.developer != null) {
targetUser.Developer = payload.developer.Value;
}
if (targetUser.Developer == false) {
targetUser.Bots.Clear();
}
// Update
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("users")]
[Authorize("Staff")]
public async Task<IResult> HandleAdminUsers(AdminUsersPayload payload)
{
// Validate orderBy
if (payload.orderBy != null && !new string[] { "id", "username", "email", "date_of_birth", "cvm_rank", "banned", "created" }.Contains(payload.orderBy))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid orderBy"
});
}
// Filter IP
IPAddress? filterIp = null;
if (payload.filterIp != null)
{
if (!IPAddress.TryParse(payload.filterIp, out filterIp)) {
HttpContext.Response.StatusCode = 400;
return Results.Json(new AdminUsersResponse
{
success = false,
error = "Invalid filterIp"
});
}
}
// Get users
IQueryable<User> result = _dbContext.Users;
if (payload.filterUsername != null) {
result = result.Where(u => u.Username.Contains(payload.filterUsername));
}
if (filterIp != null) {
result = result.Where(u => u.RegistrationIp == filterIp.GetAddressBytes());
}
var orderBy = payload.orderBy ?? "id";
var order = (Expression<Func<User, object>> k) => {
if (payload.orderByDescending) {
result = result.OrderByDescending(k);
} else {
result = result.OrderBy(k);
}
};
switch (orderBy) {
case "id":
order(u => u.Id);
break;
case "username":
order(u => u.Username);
break;
case "email":
order(u => u.Email);
break;
case "date_of_birth":
order(u => u.DateOfBirth);
break;
case "cvm_rank":
order(u => u.CvmRank);
break;
case "banned":
order(u => u.Banned);
break;
case "created":
order(u => u.Created);
break;
}
result = result.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage);
var users = await result.Select(user => new AdminUser
{
id = user.Id,
username = user.Username,
email = user.Email,
rank = user.CvmRank,
banned = user.Banned,
banReason = user.BanReason ?? "",
dateOfBirth = user.DateOfBirth.ToString("yyyy-MM-dd"),
dateJoined = user.Created.ToString("yyyy-MM-dd HH:mm:ss"),
registrationIp = new IPAddress(user.RegistrationIp).ToString(),
developer = user.Developer
}).ToArrayAsync();
return Results.Json(new AdminUsersResponse
{
success = true,
users = users,
totalPageCount = (int)Math.Ceiling(await _dbContext.Users.CountAsync() / (double)payload.resultsPerPage)
});
}
}

View File

@@ -0,0 +1,754 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Computernewb.CollabVMAuthServer.Database.Schema;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
using Isopoh.Cryptography.Argon2;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Computernewb.CollabVMAuthServer.HTTP.Controllers;
[Route("api/v1")]
[ApiController]
public class AuthenticationApiController : ControllerBase
{
private readonly CollabVMAuthDbContext _dbContext;
public AuthenticationApiController(CollabVMAuthDbContext dbContext) {
this._dbContext = dbContext;
}
[HttpPost]
[Route("sendreset")]
public async Task<IResult> HandleSendReset(SendResetEmailPayload payload)
{
if (!Program.Config.SMTP!.Enabled)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Password reset is not supported by this server. Please contact an administrator."
});
}
// Check captcha response
if (Program.Config.hCaptcha!.Enabled)
{
if (string.IsNullOrWhiteSpace(payload.captchaToken))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Missing hCaptcha token"
});
}
var result =
await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString());
if (!result.success)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid captcha response"
});
}
}
// Check username and E-Mail
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username);
if (user == null || user.Email != payload.email)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid username or E-Mail"
});
}
// Generate reset code
var code = Program.Random.Next(10000000, 99999999).ToString();
user.PasswordResetCode = code;
await _dbContext.SaveChangesAsync();
await Program.Mailer!.SendPasswordResetEmail(payload.username, payload.email, code);
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("reset")]
public async Task<IResult> HandleReset(ResetPasswordPayload payload)
{
// Is mailer enabled?
if (Program.Mailer == null)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Password reset is disabled"
});
}
// Check username and E-Mail
var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Username == payload.username);
if (user == null || user.Email != payload.email)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid username or E-Mail"
});
}
// Check if code is correct
if (user.PasswordResetCode != payload.code)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Invalid reset code"
});
}
// Validate new password
if (!Utilities.ValidatePassword(payload.newPassword))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol."
});
}
if (Program.BannedPasswords.Contains(payload.newPassword))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new ApiResponse
{
success = false,
error = "That password is commonly used and is not allowed."
});
}
// Reset password
var newPasswordHashed = Argon2.Hash(payload.newPassword);
user.Password = newPasswordHashed;
user.PasswordResetCode = null;
user.Sessions.Clear();
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("update")]
[Authorize("User")]
public async Task<IResult> HandleUpdate(UpdatePayload payload)
{
var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Id == uint.Parse(HttpContext.User.FindFirstValue("id")!));
// Check password
if (!Argon2.Verify(user!.Password, payload.currentPassword))
{
return Results.Json(new UpdateResponse
{
success = false,
error = "Invalid password",
});
}
// Validate new username
if (payload.username != null)
{
if (!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."
});
}
// Make sure username isn't taken
if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) || await _dbContext.Bots.AnyAsync(b => b.Username == payload.username))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That username is taken."
});
}
user.Username = payload.username;
}
// Validate new E-Mail
if (payload.email != null)
{
if (!new EmailAddressAttribute().IsValid(payload.email))
{
return Results.Json(new UpdateResponse
{
success = false,
error = "Malformed E-Mail address."
});
}
if (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."
});
}
// Check if E-Mail is in use
if (_dbContext.Users.Any(u => u.Email == payload.email))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That E-Mail is already in use."
});
}
user.Email = payload.email;
}
// Validate new password
if (payload.newPassword != null)
{
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."
});
}
if (Program.BannedPasswords.Contains(payload.newPassword))
{
return Results.Json(new UpdateResponse
{
success = false,
error = "That password is commonly used and is not allowed."
});
}
user.Password = Argon2.Hash(payload.newPassword);
}
// Revoke all sessions
user.Sessions.Clear();
// Unverify the account if the E-Mail was changed
if (payload.email != null && Program.Config.Registration!.EmailVerificationRequired)
{
user.EmailVerified = false;
user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString();
await Program.Mailer!.SendVerificationCode(user.Username, payload.email, user.EmailVerificationCode);
}
// Save changes
await _dbContext.SaveChangesAsync();
return Results.Json(new UpdateResponse
{
success = true,
verificationRequired = !user.EmailVerified,
sessionExpired = true
});
}
[HttpPost]
[Route("logout")]
[Authorize("User")]
public async Task<IResult> HandleLogout()
{
var user = await _dbContext.Users.Include(u => u.Sessions).FirstOrDefaultAsync(u => u.Id == uint.Parse(HttpContext.User.FindFirstValue("id")!));
// Revoke session
user!.Sessions.Clear();
await _dbContext.SaveChangesAsync();
return Results.Json(new ApiResponse
{
success = true
});
}
[HttpPost]
[Route("session")]
[Authorize("User")]
public async Task<IResult> HandleSession()
{
var user = await _dbContext.Users.FindAsync(uint.Parse(HttpContext.User.FindFirstValue("id")!));
return Results.Json(new SessionResponse
{
success = true,
banned = user!.Banned,
username = user.Username,
email = user.Email,
rank = user.CvmRank,
developer = user.Developer
});
}
[HttpPost]
[Route("join")]
public async Task<IResult> HandleJoin(JoinPayload payload)
{
// Check secret key
if (payload.secretKey != Program.Config.CollabVM!.SecretKey)
{
HttpContext.Response.StatusCode = 401;
return Results.Json(new JoinResponse
{
success = false,
error = "Invalid secret key"
});
}
// Check if IP banned
if (!IPAddress.TryParse(payload.ip, out var ip))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new JoinResponse
{
success = false,
error = "Malformed IP address"
});
}
var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == ip.GetAddressBytes());
if (ban != null)
{
HttpContext.Response.StatusCode = 200;
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = false,
error = "Banned",
banned = true,
banReason = ban.Reason
});
}
// Check if session is valid
if (payload.sessionToken.Length == 32)
{
// User
var session = await _dbContext.Sessions.Include(s => s.UserNavigation).FirstOrDefaultAsync(s => s.Token == payload.sessionToken);
if (session == null)
{
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = false,
error = "Invalid session",
});
}
// 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",
});
}
// Check if banned
if (session.UserNavigation.Banned)
{
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = false,
banned = true,
error = "Banned",
banReason = session.UserNavigation.BanReason
});
}
// Update session
session.LastUsed = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = true,
username = session.UserNavigation.Username,
rank = session.UserNavigation.CvmRank
});
} else if (payload.sessionToken.Length == 64)
{
// Bot
var bot = await _dbContext.Bots.FirstOrDefaultAsync(b => b.Token == payload.sessionToken);
if (bot == null)
{
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = false,
error = "Invalid session",
});
}
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = true,
username = bot.Username,
rank = bot.CvmRank
});
}
else
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new JoinResponse
{
success = true,
clientSuccess = false,
error = "Invalid session"
});
}
}
[HttpPost]
[Route("login")]
public async Task<IResult> HandleLogin(LoginPayload payload)
{
// Check captcha response
if (Program.Config.hCaptcha!.Enabled)
{
if (string.IsNullOrWhiteSpace(payload.captchaToken))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new LoginResponse
{
success = false,
error = "Missing hCaptcha token"
});
}
var result =
await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString());
if (!result.success)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new LoginResponse
{
success = false,
error = "Invalid captcha response"
});
}
}
// Validate username and password
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username);
if (user == null || !Argon2.Verify(user.Password, payload.password))
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new LoginResponse
{
success = false,
error = "Invalid username or password"
});
}
// Check if IP banned
var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == HttpContext.Connection.RemoteIpAddress!.GetAddressBytes());
if (ban != null)
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new LoginResponse
{
success = false,
error = $"You are banned: {ban.Reason}"
});
}
// Check if account is verified
if (!user.EmailVerified && Program.Config.Registration!.EmailVerificationRequired)
{
if (user.EmailVerificationCode == null) {
user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString();
await _dbContext.SaveChangesAsync();
await Program.Mailer!.SendVerificationCode(user.Username, user.Email, user.EmailVerificationCode);
}
return Results.Json(new LoginResponse
{
success = true,
verificationRequired = true,
email = user.Email,
username = user.Username,
rank = user.CvmRank,
developer = user.Developer
});
}
// Check max sessions
var sessions = await _dbContext.Sessions.Include(s => s.UserNavigation).CountAsync(s => s.UserNavigation.Username == user.Username);
if (sessions >= Program.Config.Accounts!.MaxSessions)
{
var oldest = await _dbContext.Sessions.Include(s => s.UserNavigation).Where(s => s.UserNavigation.Username == user.Username).OrderBy(s => s.LastUsed).FirstAsync();
_dbContext.Sessions.Remove(oldest);
await _dbContext.SaveChangesAsync();
}
// Perform sign-in
var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())]));
await HttpContext.SignInAsync(userPrincipal);
var token = userPrincipal.FindFirstValue("token")
?? throw new InvalidOperationException("Sign in handler did not add token");
return Results.Json(new LoginResponse
{
success = true,
token = token,
username = user.Username,
email = user.Email,
rank = user.CvmRank,
developer = user.Developer
});
}
[HttpPost]
[Route("verify")]
public async Task<IResult> HandleVerify(VerifyPayload payload)
{
// Validate username and password
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == payload.username);
if (user == null || !Argon2.Verify(user.Password, payload.password))
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new VerifyResponse
{
success = false,
error = "Invalid username or password"
});
}
// Check if account is verified
if (user.EmailVerified)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new VerifyResponse
{
success = false,
error = "Account is already verified"
});
}
// Check if code is correct
if (user.EmailVerificationCode != payload.code)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new VerifyResponse
{
success = false,
error = "Invalid verification code"
});
}
// Verify the account
user.EmailVerified = true;
// Create a session
var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())]));
await HttpContext.SignInAsync(userPrincipal);
var token = userPrincipal.FindFirstValue("token")
?? throw new InvalidOperationException("Sign in handler did not add token");
await _dbContext.SaveChangesAsync();
return Results.Json(new VerifyResponse
{
success = true,
sessionToken = token,
});
}
[HttpPost]
[Route("register")]
public async Task<IResult> HandleRegister(RegisterPayload payload)
{
// Check if IP banned
var ban = await _dbContext.IpBans.FirstOrDefaultAsync(b => b.Ip == HttpContext.Connection.RemoteIpAddress!.GetAddressBytes());
if (ban != null)
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new RegisterResponse
{
success = false,
error = $"You are banned: {ban.Reason}"
});
}
// Check captcha response
if (Program.Config.hCaptcha!.Enabled)
{
if (string.IsNullOrWhiteSpace(payload.captchaToken))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Missing hCaptcha token"
});
}
var result =
await Program.hCaptcha!.Verify(payload.captchaToken, HttpContext.Connection.RemoteIpAddress!.ToString());
if (!result.success)
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Invalid captcha response"
});
}
}
// Make sure username isn't taken
if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) || await _dbContext.Bots.AnyAsync(b => b.Username == payload.username))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That username is taken."
});
}
// Check if E-Mail is in use
if (await _dbContext.Users.AnyAsync(u => u.Email == payload.email))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That E-Mail is already in use."
});
}
// Validate username
if (!Utilities.ValidateUsername(payload.username))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters."
});
}
// Validate E-Mail
if (!new EmailAddressAttribute().IsValid(payload.email))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Malformed E-Mail address."
});
}
if (Program.Config.Registration!.EmailDomainWhitelist &&
!Program.Config.Registration.AllowedEmailDomains!.Contains(payload.email.Split("@")[1]))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That E-Mail domain is not allowed."
});
}
// Validate password
if (!Utilities.ValidatePassword(payload.password))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Passwords must be at least 8 characters and must contain an uppercase and lowercase letter, a number, and a symbol."
});
}
if (Program.BannedPasswords.Contains(payload.password))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "That password is commonly used and is not allowed."
});
}
// Validate date of birth
if (!DateOnly.TryParseExact(payload.dateOfBirth, "yyyy-MM-dd", out var dob))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Invalid date of birth"
});
}
if (dob.AddYears(13) > DateOnly.FromDateTime(DateTime.Now))
{
HttpContext.Response.StatusCode = 400;
await _dbContext.IpBans.AddAsync(new IpBan {
Ip = HttpContext.Connection.RemoteIpAddress!.GetAddressBytes(),
Reason = "You are not old enough to use CollabVM.",
BannedAt = DateTime.UtcNow
});
await _dbContext.SaveChangesAsync();
return Results.Json(new RegisterResponse
{
success = false,
error = "You are not old enough to use CollabVM."
});
}
// theres no fucking chance
if (dob < new DateOnly(1954, 1, 1))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new RegisterResponse
{
success = false,
error = "Are you sure about that?"
});
}
// Create the account
string? token = null;
var user = new User {
Username = payload.username,
Password = Argon2.Hash(payload.password),
Email = payload.email,
DateOfBirth = dob,
// If this is the first user, make them an admin
CvmRank = (uint) ((await _dbContext.Users.AnyAsync()) ? 1 : 2),
RegistrationIp = HttpContext.Connection.RemoteIpAddress!.GetAddressBytes(),
Created = DateTime.UtcNow,
};
_dbContext.Users.Add(user);
if (Program.Config.Registration.EmailVerificationRequired)
{
user.EmailVerificationCode = Program.Random.Next(10000000, 99999999).ToString();
await _dbContext.SaveChangesAsync();
await Program.Mailer!.SendVerificationCode(user.Username, user.Email, user.EmailVerificationCode);
}
else
{
user.EmailVerified = true;
await _dbContext.SaveChangesAsync();
var userPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new("id", user.Id.ToString())]));
await HttpContext.SignInAsync(userPrincipal);
token = userPrincipal.FindFirstValue("token")
?? throw new InvalidOperationException("Sign in handler did not add token");
}
return Results.Json(new RegisterResponse
{
success = true,
verificationRequired = !user.EmailVerified,
email = user.Email,
username = user.Username,
sessionToken = token
});
}
[HttpGet]
[Route("info")]
public IResult HandleInfo()
{
return Results.Json(new AuthServerInformation
{
// TODO: Implement registration closure
registrationOpen = true,
hcaptcha =
new() {
required = Program.Config.hCaptcha!.Enabled,
siteKey = Program.Config.hCaptcha.Enabled ? Program.Config.hCaptcha.SiteKey : null
}
});
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Computernewb.CollabVMAuthServer.Database.Schema;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Computernewb.CollabVMAuthServer.HTTP.Controllers;
[Route("api/v1/bots")]
[ApiController]
[Authorize("Developer")]
public class DeveloperApiController : ControllerBase {
private readonly CollabVMAuthDbContext _dbContext;
public DeveloperApiController(CollabVMAuthDbContext dbContext) {
this._dbContext = dbContext;
}
[HttpPost]
[Route("list")]
public async Task<IResult> HandleListBots(ListBotsPayload payload)
{
// owner can only be specified by admins and moderators
if (payload.owner != null && !(User.HasClaim("rank", "2") || User.HasClaim("rank", "3")))
{
HttpContext.Response.StatusCode = 403;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Insufficient permissions"
});
}
// Get bots
// If the user is not an admin, they can only see their own bots
IQueryable<Bot> result = _dbContext.Bots.Include(b => b.OwnerNavigation);
if (payload.owner != null) {
result = result.Where(b => b.OwnerNavigation.Username == payload.owner);
} else if (!User.HasClaim("rank", "2") && !User.HasClaim("rank", "3")) {
result = result.Where(b => b.OwnerNavigation.Username == User.FindFirstValue("username")!);
}
result = result.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage);
var bots = await result.Select(bot => new ListBot
{
id = (int)bot.Id,
username = bot.Username,
rank = bot.CvmRank,
owner = bot.OwnerNavigation.Username,
created = bot.Created.ToString("yyyy-MM-dd HH:mm:ss")
}).ToArrayAsync();
return Results.Json(new ListBotsResponse
{
success = true,
totalPageCount = (int)Math.Ceiling(await _dbContext.Bots.CountAsync() / (double)payload.resultsPerPage),
bots = bots
});
}
[HttpPost]
[Route("create")]
public async Task<IResult> HandleCreateBot(CreateBotPayload payload)
{
// Check bot username
if (await _dbContext.Users.AnyAsync(u => u.Username == payload.username) ||
await _dbContext.Bots.AnyAsync(b => b.Username == payload.username))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "That username is taken."
});
}
if (!Utilities.ValidateUsername(payload.username))
{
HttpContext.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error =
"Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters."
});
}
// Generate token
string token = Utilities.RandomString(64);
// Create bot
var bot = new Bot {
Username = payload.username,
Token = token,
CvmRank = 1,
Owner = uint.Parse(HttpContext.User.FindFirstValue("id")!),
Created = DateTime.UtcNow
};
_dbContext.Bots.Add(bot);
await _dbContext.SaveChangesAsync();
return Results.Json(new CreateBotResponse
{
success = true,
token = token
});
}
}

View File

@@ -1,196 +0,0 @@
using System.Text.Json;
using Computernewb.CollabVMAuthServer.HTTP.Payloads;
using Computernewb.CollabVMAuthServer.HTTP.Responses;
namespace Computernewb.CollabVMAuthServer.HTTP;
public static class DeveloperRoutes
{
public static void RegisterRoutes(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/bots/create", (Delegate)HandleCreateBot);
app.MapPost("/api/v1/bots/list", (Delegate)HandleListBots);
}
private static async Task<IResult> HandleListBots(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
ListBotsPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<ListBotsPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || payload.resultsPerPage <= 0 ||
payload.page <= 0)
{
context.Response.StatusCode = 400;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check developer status
var user = await Program.Database.GetUser(session.Username) ??
throw new Exception("Unable to get user from session");
if (!user.Developer && user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new CreateBotResponse
{
success = false,
error = "You must be an approved developer to create and manage bots."
}, Utilities.JsonSerializerOptions);
}
// owner can only be specified by admins and moderators
if (payload.owner != null && user.Rank != Rank.Admin && user.Rank != Rank.Moderator)
{
context.Response.StatusCode = 403;
return Results.Json(new ListBotsResponse
{
success = false,
error = "Insufficient permissions"
}, Utilities.JsonSerializerOptions);
}
// Get bots
// If the user is not an admin, they can only see their own bots
var bots = (await Program.Database.ListBots(payload.owner ?? ((user.Rank == Rank.Admin || user.Rank == Rank.Moderator) ? null : user.Username))).Select(bot => new ListBot
{
id = (int)bot.Id,
username = bot.Username,
rank = (int)bot.Rank,
owner = bot.Owner,
created = bot.Created.ToString("yyyy-MM-dd HH:mm:ss")
});
var page = bots.Skip((payload.page - 1) * payload.resultsPerPage).Take(payload.resultsPerPage).ToArray();
return Results.Json(new ListBotsResponse
{
success = true,
totalPageCount = (int)Math.Ceiling(bots.Count() / (double)payload.resultsPerPage),
bots = page
}, Utilities.JsonSerializerOptions);
}
private static async Task<IResult> HandleCreateBot(HttpContext context)
{
// Check payload
if (context.Request.ContentType != "application/json")
{
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
CreateBotPayload? payload;
try
{
payload = await context.Request.ReadFromJsonAsync<CreateBotPayload>();
}
catch (JsonException ex)
{
Utilities.Log(LogLevel.DEBUG, $"Failed to parse JSON: {ex.Message}");
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
if (payload == null || string.IsNullOrWhiteSpace(payload.token) || string.IsNullOrWhiteSpace(payload.username))
{
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "Invalid request body"
}, Utilities.JsonSerializerOptions);
}
// Check token
var session = await Program.Database.GetSession(payload.token);
if (session == null || Utilities.IsSessionExpired(session))
{
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "Invalid session"
}, Utilities.JsonSerializerOptions);
}
// Check developer status
var user = await Program.Database.GetUser(session.Username) ??
throw new Exception("Unable to get user from session");
if (!user.Developer)
{
context.Response.StatusCode = 403;
return Results.Json(new CreateBotResponse
{
success = false,
error = "You must be an approved developer to create and manage bots."
}, Utilities.JsonSerializerOptions);
}
// Check bot username
if (await Program.Database.GetBot(payload.username) != null ||
await Program.Database.GetUser(payload.username) != null)
{
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error = "That username is taken."
}, Utilities.JsonSerializerOptions);
}
if (!Utilities.ValidateUsername(payload.username))
{
context.Response.StatusCode = 400;
return Results.Json(new CreateBotResponse
{
success = false,
error =
"Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and must be between 3 and 20 characters."
}, Utilities.JsonSerializerOptions);
}
// Generate token
string token = Utilities.RandomString(64);
// Create bot
await Program.Database.CreateBot(payload.username, token, user.Username);
return Results.Json(new CreateBotResponse
{
success = true,
token = token
}, Utilities.JsonSerializerOptions);
}
}

View File

@@ -2,7 +2,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class AdminUpdateBotPayload public class AdminUpdateBotPayload
{ {
public string token { get; set; } public required string username { get; set; }
public string username { get; set; } public uint? rank { get; set; }
public int? rank { get; set; }
} }

View File

@@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class AdminUpdateUserPayload public class AdminUpdateUserPayload
{ {
public string token { get; set; } public required string username { get; set; }
public string username { get; set; } public uint? rank { get; set; }
public int? rank { get; set; }
public bool? developer { get; set; } = null; public bool? developer { get; set; } = null;
} }

View File

@@ -2,10 +2,10 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class AdminUsersPayload public class AdminUsersPayload
{ {
public string token { get; set; }
public int resultsPerPage { get; set; } public int resultsPerPage { get; set; }
public int page { get; set; } public int page { get; set; }
public string? filterUsername { get; set; } public string? filterUsername { get; set; }
public string? filterIp { get; set; }
public string? orderBy { get; set; } public string? orderBy { get; set; }
public bool orderByDescending { get; set; } = false; public bool orderByDescending { get; set; } = false;
} }

View File

@@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class BanUserPayload public class BanUserPayload
{ {
public string token { get; set; } public required string username { get; set; }
public string username { get; set; }
public bool banned { get; set; } public bool banned { get; set; }
public string reason { get; set; } public string? reason { get; set; }
} }

View File

@@ -2,6 +2,5 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class CreateBotPayload public class CreateBotPayload
{ {
public string token { get; set; } public required string username { get; set; }
public string username { get; set; }
} }

View File

@@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class IPBanPayload public class IPBanPayload
{ {
public string session { get; set; } public required string ip { get; set; }
public string ip { get; set; }
public bool banned { get; set; } public bool banned { get; set; }
public string reason { get; set; } public required string reason { get; set; }
} }

View File

@@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class JoinPayload public class JoinPayload
{ {
public string secretKey { get; set; } public required string secretKey { get; set; }
public string sessionToken { get; set; } public required string sessionToken { get; set; }
public string ip { get; set; } public required string ip { get; set; }
} }

View File

@@ -2,7 +2,6 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class ListBotsPayload public class ListBotsPayload
{ {
public string token { get; set; }
public int resultsPerPage { get; set; } public int resultsPerPage { get; set; }
public int page { get; set; } public int page { get; set; }
public string? owner { get; set; } public string? owner { get; set; }

View File

@@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class LoginPayload public class LoginPayload
{ {
public string username { get; set; } public required string username { get; set; }
public string password { get; set; } public required string password { get; set; }
public string? captchaToken { get; set; } public string? captchaToken { get; set; }
} }

View File

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

View File

@@ -2,9 +2,9 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class RegisterPayload public class RegisterPayload
{ {
public string username { get; set; } public required string username { get; set; }
public string password { get; set; } public required string password { get; set; }
public string email { get; set; } public required string email { get; set; }
public string? captchaToken { get; set; } public string? captchaToken { get; set; }
public string dateOfBirth { get; set; } public required string dateOfBirth { get; set; }
} }

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class RequestBodyAuthenticationPayload {
[JsonPropertyName("session")]
public string? Session { get; set; }
[JsonPropertyName("token")]
public string? Token { get; set; }
}

View File

@@ -2,8 +2,8 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class ResetPasswordPayload public class ResetPasswordPayload
{ {
public string username { get; set; } public required string username { get; set; }
public string email { get; set; } public required string email { get; set; }
public string code { get; set; } public required string code { get; set; }
public string newPassword { get; set; } public required string newPassword { get; set; }
} }

View File

@@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class SendResetEmailPayload public class SendResetEmailPayload
{ {
public string email { get; set; } public required string email { get; set; }
public string username { get; set; } public required string username { get; set; }
public string? captchaToken { get; set; } public string? captchaToken { get; set; }
} }

View File

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

View File

@@ -2,8 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class UpdatePayload public class UpdatePayload
{ {
public string token { get; set; } public required string currentPassword { get; set; }
public string currentPassword { get; set; }
public string? newPassword { get; set; } public string? newPassword { get; set; }
public string? username { get; set; } public string? username { get; set; }

View File

@@ -2,7 +2,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Payloads;
public class VerifyPayload public class VerifyPayload
{ {
public string username { get; set; } public required string username { get; set; }
public string password { get; set; } public required string password { get; set; }
public string code { get; set; } public required string code { get; set; }
} }

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class AdminUsersResponse public class AdminUsersResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public int? totalPageCount { get; set; } = null; public int? totalPageCount { get; set; } = null;
public AdminUser[]? users { get; set; } public AdminUser[]? users { get; set; }
} }
@@ -11,13 +9,13 @@ public class AdminUsersResponse
public class AdminUser public class AdminUser
{ {
public uint id { get; set; } public uint id { get; set; }
public string username { get; set; } public required string username { get; set; }
public string email { get; set; } public required string email { get; set; }
public int rank { get; set; } public required uint rank { get; set; }
public bool banned { get; set; } public bool banned { get; set; }
public string banReason { get; set; } public required string banReason { get; set; }
public string dateOfBirth { get; set; } public required string dateOfBirth { get; set; }
public string dateJoined { get; set; } public required string dateJoined { get; set; }
public string registrationIp { get; set; } public required string registrationIp { get; set; }
public bool developer { get; set; } public bool developer { get; set; }
} }

View File

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

View File

@@ -3,7 +3,7 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class AuthServerInformation public class AuthServerInformation
{ {
public bool registrationOpen { get; set; } public bool registrationOpen { get; set; }
public AuthServerInformationCaptcha hcaptcha { get; set; } public required AuthServerInformationCaptcha hcaptcha { get; set; }
} }
public class AuthServerInformationCaptcha public class AuthServerInformationCaptcha

View File

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

View File

@@ -1,8 +1,6 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class CreateBotResponse public class CreateBotResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public string? token { get; set; } public string? token { get; set; }
} }

View File

@@ -1,12 +1,10 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class JoinResponse public class JoinResponse : ApiResponse
{ {
public bool success { get; set; }
public bool clientSuccess { get; set; } = false; public bool clientSuccess { get; set; } = false;
public bool? banned { get; set; } = null; public bool? banned { get; set; } = null;
public string? banReason { get; set; } public string? banReason { get; set; }
public string? error { get; set; }
public string? username { get; set; } public string? username { get; set; }
public Rank? rank { get; set; } public uint? rank { get; set; }
} }

View File

@@ -3,8 +3,8 @@ namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class ListBot public class ListBot
{ {
public int id { get; set; } public int id { get; set; }
public string username { get; set; } public required string username { get; set; }
public int rank { get; set; } public uint rank { get; set; }
public string owner { get; set; } public required string owner { get; set; }
public string created { get; set; } public required string created { get; set; }
} }

View File

@@ -1,9 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class ListBotsResponse public class ListBotsResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public int? totalPageCount { get; set; } = null; public int? totalPageCount { get; set; } = null;
public ListBot[]? bots { get; set; } public ListBot[]? bots { get; set; }
} }

View File

@@ -1,13 +1,11 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class LoginResponse public class LoginResponse : ApiResponse
{ {
public bool success { get; set; }
public string? token { get; set; } public string? token { get; set; }
public string? error { get; set; }
public bool? verificationRequired { get; set; } public bool? verificationRequired { get; set; }
public string? email { get; set; } public string? email { get; set; }
public string? username { get; set; } public string? username { get; set; }
public int rank { get; set; } public uint rank { get; set; }
public bool? developer { get; set; } = null; public bool? developer { get; set; } = null;
} }

View File

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

View File

@@ -1,9 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class RegisterResponse public class RegisterResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
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; }

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class SessionResponse public class SessionResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public bool banned { get; set; } = false; public bool banned { get; set; } = false;
public string? username { get; set; } public string? username { get; set; }
public string? email { get; set; } public string? email { get; set; }
public int rank { get; set; } public uint rank { get; set; }
public bool? developer { get; set; } = null; public bool? developer { get; set; } = null;
} }

View File

@@ -1,9 +1,7 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class UpdateResponse public class UpdateResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public bool? verificationRequired { get; set; } = null; public bool? verificationRequired { get; set; } = null;
public bool? sessionExpired { get; set; } = null; public bool? sessionExpired { get; set; } = null;
} }

View File

@@ -1,8 +1,6 @@
namespace Computernewb.CollabVMAuthServer.HTTP.Responses; namespace Computernewb.CollabVMAuthServer.HTTP.Responses;
public class VerifyResponse public class VerifyResponse : ApiResponse
{ {
public bool success { get; set; }
public string? error { get; set; }
public string? sessionToken { get; set; } public string? sessionToken { get; set; }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,30 @@
using System.IO;
using Computernewb.CollabVMAuthServer.Database;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using Tomlet;
using Tomlet.Attributes;
namespace Computernewb.CollabVMAuthServer; 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 AccountConfig? Accounts { get; set; }
public CollabVMConfig CollabVM { 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; }
/// <summary>Load config instance from the specified toml file</summary>
public static IConfig Load(string configPath) {
// Load from disk
var configRaw = File.ReadAllText(configPath);
// Parse toml
var config = TomletMain.To<IConfig>(configRaw);
return config;
}
} }
@@ -16,7 +32,7 @@ public class RegistrationConfig
{ {
public bool EmailVerificationRequired { get; set; } public bool EmailVerificationRequired { get; set; }
public bool EmailDomainWhitelist { get; set; } public bool EmailDomainWhitelist { get; set; }
public string[] AllowedEmailDomains { get; set; } public string[]? AllowedEmailDomains { get; set; }
} }
public class AccountConfig public class AccountConfig
@@ -28,21 +44,38 @@ public class AccountConfig
public class CollabVMConfig public class CollabVMConfig
{ {
// We might want to move this to the database, but for now it's fine here. // We might want to move this to the database, but for now it's fine here.
public string SecretKey { get; set; } 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 bool UseXForwardedFor { get; set; }
public string[] TrustedProxies { get; set; } public string[]? TrustedProxies { get; set; }
} }
public class MySQLConfig public class MySQLConfig
{ {
public string Host { get; set; } [TomlNonSerialized]
public string Username { get; set; } public string ConnectionString => new MySqlConnectionStringBuilder {
public string Password { get; set; } Server = Host,
public string Database { get; set; } UserID = Username,
Password = Password,
Database = Database
}.ConnectionString;
public string? Host { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Database { get; set; }
public DbContextOptionsBuilder<CollabVMAuthDbContext> Configure(DbContextOptionsBuilder<CollabVMAuthDbContext>? builder = null) {
return (builder ?? new DbContextOptionsBuilder<CollabVMAuthDbContext>())
.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString));
}
public DbContextOptionsBuilder Configure(DbContextOptionsBuilder builder) {
return (builder ?? new DbContextOptionsBuilder<CollabVMAuthDbContext>())
.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString));
}
} }
public class SMTPConfig public class SMTPConfig

View File

@@ -1,11 +0,0 @@
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

@@ -1,12 +1,16 @@
using System;
using System.Threading.Tasks;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Logging;
using MimeKit; using MimeKit;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
public class Mailer public class Mailer
{ {
private SMTPConfig Config; private readonly SMTPConfig Config;
private readonly ILogger _logger;
public Mailer(SMTPConfig config) public Mailer(SMTPConfig config)
{ {
if (config.Host == null || config.Port == null || config.Username == null || config.Password == null || if (config.Host == null || config.Port == null || config.Username == null || config.Password == null ||
@@ -14,10 +18,10 @@ public class Mailer
config.VerificationCodeBody == null || config.ResetPasswordSubject == null || config.VerificationCodeBody == null || config.ResetPasswordSubject == null ||
config.ResetPasswordBody == null) config.ResetPasswordBody == null)
{ {
Utilities.Log(LogLevel.FATAL,"SMTPConfig is missing required fields"); throw new InvalidOperationException("SMTPConfig is missing required fields");
Environment.Exit(1);
} }
Config = config; Config = config;
_logger = LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger<Mailer>();
} }
public async Task SendVerificationCode(string username, string email, string code) public async Task SendVerificationCode(string username, string email, string code)
@@ -25,23 +29,23 @@ public class Mailer
var message = new MimeMessage(); var message = new MimeMessage();
message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail)); message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail));
message.To.Add(new MailboxAddress(username, email)); message.To.Add(new MailboxAddress(username, email));
message.Subject = Config.VerificationCodeSubject message.Subject = Config.VerificationCodeSubject!
.Replace("$USERNAME", username) .Replace("$USERNAME", username)
.Replace("$EMAIL", email) .Replace("$EMAIL", email)
.Replace("$CODE", code); .Replace("$CODE", code);
message.Body = new TextPart("plain") message.Body = new TextPart("plain")
{ {
Text = Config.VerificationCodeBody Text = Config.VerificationCodeBody!
.Replace("$USERNAME", username) .Replace("$USERNAME", username)
.Replace("$EMAIL", email) .Replace("$EMAIL", email)
.Replace("$CODE", code) .Replace("$CODE", code)
}; };
using var client = new SmtpClient(); using var client = new SmtpClient();
await client.ConnectAsync(Config.Host, (int)Config.Port, SecureSocketOptions.StartTlsWhenAvailable); await client.ConnectAsync(Config.Host, (int)Config.Port!, SecureSocketOptions.StartTlsWhenAvailable);
await client.AuthenticateAsync(Config.Username, Config.Password); await client.AuthenticateAsync(Config.Username, Config.Password);
await client.SendAsync(message); await client.SendAsync(message);
await client.DisconnectAsync(true); await client.DisconnectAsync(true);
Utilities.Log(LogLevel.INFO, $"Sent e-mail verification code to {username} <{email}>"); _logger.LogInformation("Sent e-mail verification code to {username} <{email}>", username, email);
} }
public async Task SendPasswordResetEmail(string username, string email, string code) public async Task SendPasswordResetEmail(string username, string email, string code)
@@ -49,23 +53,23 @@ public class Mailer
var message = new MimeMessage(); var message = new MimeMessage();
message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail)); message.From.Add(new MailboxAddress(Config.FromName, Config.FromEmail));
message.To.Add(new MailboxAddress(username, email)); message.To.Add(new MailboxAddress(username, email));
message.Subject = Config.ResetPasswordSubject message.Subject = Config.ResetPasswordSubject!
.Replace("$USERNAME", username) .Replace("$USERNAME", username)
.Replace("$EMAIL", email) .Replace("$EMAIL", email)
.Replace("$CODE", code); .Replace("$CODE", code);
message.Body = new TextPart("plain") message.Body = new TextPart("plain")
{ {
Text = Config.ResetPasswordBody Text = Config.ResetPasswordBody!
.Replace("$USERNAME", username) .Replace("$USERNAME", username)
.Replace("$EMAIL", email) .Replace("$EMAIL", email)
.Replace("$CODE", code) .Replace("$CODE", code)
}; };
using var client = new SmtpClient(); using var client = new SmtpClient();
await client.ConnectAsync(Config.Host, (int)Config.Port, SecureSocketOptions.StartTlsWhenAvailable); await client.ConnectAsync(Config.Host, (int)Config.Port!, SecureSocketOptions.StartTlsWhenAvailable);
await client.AuthenticateAsync(Config.Username, Config.Password); await client.AuthenticateAsync(Config.Username, Config.Password);
await client.SendAsync(message); await client.SendAsync(message);
await client.DisconnectAsync(true); await client.DisconnectAsync(true);
Utilities.Log(LogLevel.INFO, $"Sent password reset verification code to {username} <{email}>"); _logger.LogInformation("Sent password reset verification code to {username} <{email}>", username, email);
} }
} }

View File

@@ -1,101 +1,176 @@
using System;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Reflection; using System.Reflection;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Computernewb.CollabVMAuthServer.Database;
using Computernewb.CollabVMAuthServer.HTTP; using Computernewb.CollabVMAuthServer.HTTP;
using Tomlet; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
public class Program public class Program
{ {
#pragma warning disable CS8618
public static IConfig Config { get; private set; } public static IConfig Config { get; private set; }
public static Database Database { get; private set; }
public static hCaptchaClient? hCaptcha { get; private set; } public static hCaptchaClient? hCaptcha { get; private set; }
public static Mailer? Mailer { get; private set; } public static Mailer? Mailer { get; private set; }
public static string[] BannedPasswords { get; set; } public static string[] BannedPasswords { get; set; }
#pragma warning restore CS8618
public static readonly Random Random = new Random(); public static readonly Random Random = new Random();
public static async Task Main(string[] args) private static readonly ILogger _logger
= LoggerFactory.Create(Utilities.ConfigureLogging).CreateLogger<Program>();
public static async Task<int> Main(string[] args) {
if (EF.IsDesignTime) {
// We have a design time factory EF uses to handle this, just exit
return 0;
}
// Root command (run auth server)
var rootCommand = new RootCommand("CollabVM Authentication Server");
var configPathOption = new Option<string>(
name: "--config-path",
description: "Configuration file to use",
getDefaultValue: () => "./config.toml"
);
rootCommand.Add(configPathOption);
// Migrate DB
var migrateDbCommand = new Command("migrate-db", "Runs all pending database migrations");
rootCommand.Add(migrateDbCommand);
rootCommand.SetHandler(RunAuthServer, new AuthServerCliOptionsBinder(configPathOption));
migrateDbCommand.SetHandler(MigrateDatabase, new AuthServerCliOptionsBinder(configPathOption));
return await rootCommand.InvokeAsync(args);
}
public static async Task<int> MigrateDatabase(AuthServerContext context) {
_logger.LogInformation("Running database migrations");
// Initialize database
var db = new CollabVMAuthDbContext(context.Config.MySQL!.Configure().Options);
// Detect and migrate legacy schema
await LegacyDbMigrator.CheckAndMigrate(db);
// Run migrations
_logger.LogInformation("Applying {cnt} migrations now...", (await db.Database.GetPendingMigrationsAsync()).Count());
await db.Database.MigrateAsync();
_logger.LogInformation("Finished migrations.");
return 0;
}
public static async Task<int> RunAuthServer(AuthServerContext context)
{ {
var ver = Assembly.GetExecutingAssembly().GetName().Version; var ver = Assembly.GetExecutingAssembly().GetName().Version;
Utilities.Log(LogLevel.INFO, $"CollabVM Authentication Server v{ver.Major}.{ver.Minor}.{ver.Revision} starting up"); _logger.LogInformation("CollabVM Authentication Server v{major}.{minor}.{revision} starting up", ver!.Major, ver.Minor, ver.Revision);
// Read config.toml // temp
string configraw; Config = context.Config;
try
{
configraw = File.ReadAllText("config.toml");
}
catch (Exception ex)
{
Utilities.Log(LogLevel.FATAL, "Failed to read config.toml: " + ex.Message);
Environment.Exit(1);
return;
}
// Parse config.toml to IConfig
try
{
Config = TomletMain.To<IConfig>(configraw);
} catch (Exception ex)
{
Utilities.Log(LogLevel.FATAL, "Failed to parse config.toml: " + ex.Message);
Environment.Exit(1);
return;
}
// Initialize database // Initialize database
Database = new Database(Config.MySQL); var db = new CollabVMAuthDbContext(context.Config.MySQL!.Configure().Options);
// Get version before initializing // Make sure database schema is up-to-date, error if not
int dbversion = await Database.GetDatabaseVersion(); if ((await db.Database.GetPendingMigrationsAsync()).Any()) {
Utilities.Log(LogLevel.INFO, "Connected to database"); _logger.LogCritical("Database schema out of date. Please run migrations.");
Utilities.Log(LogLevel.INFO, dbversion == -1 ? "Initializing tables..." : $"Database version: {dbversion}"); return 1;
await Database.Init(); }
// If database was version 0, that should now be set, as versioning did not exist then // Count users in database
if (dbversion == 0) await Database.SetDatabaseVersion(0); var uc = await db.Users.CountAsync();
// If database was -1, that means it was just initialized and we should set it to the current version _logger.LogInformation("{uc} users in database", uc);
if (dbversion == -1) await Database.SetDatabaseVersion(DatabaseUpdate.CurrentVersion); if (uc == 0) _logger.LogWarning("No users in database, first user will be promoted to admin");
// Perform any necessary database updates // Init cron
await DatabaseUpdate.Update(Database); var cron = new Cron(context.Config.MySQL.Configure().Options);
var uc = await Database.CountUsers(); await cron.Start();
Utilities.Log(LogLevel.INFO, $"{uc} users in database");
if (uc == 0) Utilities.Log(LogLevel.WARN, "No users in database, first user will be promoted to admin");
// Create mailer // Create mailer
if (!Config.SMTP.Enabled && Config.Registration.EmailVerificationRequired) if (!Config.SMTP!.Enabled && Config.Registration!.EmailVerificationRequired)
{ {
Utilities.Log(LogLevel.FATAL, "Email verification is required but SMTP is disabled"); _logger.LogCritical("Email verification is required but SMTP is disabled");
Environment.Exit(1); return 1;
return;
} }
Mailer = Config.SMTP.Enabled ? new Mailer(Config.SMTP) : null; Mailer = Config.SMTP.Enabled ? new Mailer(Config.SMTP) : null;
// Create hCaptcha client // Create hCaptcha client
if (Config.hCaptcha.Enabled) if (Config.hCaptcha!.Enabled)
{ {
hCaptcha = new hCaptchaClient(Config.hCaptcha.Secret!, Config.hCaptcha.SiteKey!); hCaptcha = new hCaptchaClient(Config.hCaptcha.Secret!, Config.hCaptcha.SiteKey!);
Utilities.Log(LogLevel.INFO, "hCaptcha enabled"); _logger.LogInformation("hCaptcha enabled");
} }
else else
{ {
Utilities.Log(LogLevel.INFO, "hCaptcha disabled"); _logger.LogInformation("hCaptcha disabled");
} }
// load password list // load password list
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();
#if DEBUG Utilities.ConfigureLogging(builder.Logging);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug);
#else // Configure json serialization
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning); builder.Services.AddControllers().AddJsonOptions((options) => {
#endif options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// Configure database context
builder.Services.AddDbContext<CollabVMAuthDbContext>((builder) => context.Config.MySQL.Configure(builder));
// Configure forwarded headers
builder.Services.Configure<ForwardedHeadersOptions>((options) => {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in context.Config.HTTP!.TrustedProxies!) {
options.KnownProxies.Add(IPAddress.Parse(proxy));
}
});
// Configure authentication
builder.Services.AddAuthentication((options) => {
options.DefaultScheme = "CollabVM";
options.RequireAuthenticatedSignIn = false;
})
.AddScheme<CollabVMAuthenticationSchemeOptions, CollabVMAuthenticationHandler>("CollabVM", (options) => {
options.DbContextOptions = context.Config.MySQL.Configure().Options;
});
// Configure authorization policies
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, CollabVMAuthorizationMiddlewareResultHandler>();
var authorization = builder.Services.AddAuthorizationBuilder();
authorization.AddPolicy("User", (policy) => {
policy.RequireAuthenticatedUser();
policy.RequireClaim("type", "user");
});
authorization.AddPolicy("Staff", (policy) => {
policy.RequireAuthenticatedUser();
policy.RequireClaim("rank", "2", "3");
});
authorization.AddPolicy("Developer", (policy) => {
policy.RequireAuthenticatedUser();
policy.RequireClaim("developer", "1");
});
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(); builder.Services.AddCors();
var app = builder.Build(); var app = builder.Build();
if (context.Config.HTTP!.UseXForwardedFor) {
app.UseForwardedHeaders();
}
app.UseRouting(); app.UseRouting();
// TODO: Make this more strict // TODO: Make this more strict
app.UseCors(cors => cors.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); 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.UseAuthentication();
app.UseAuthorization();
// Register routes // Register routes
Routes.RegisterRoutes(app); app.MapControllers();
AdminRoutes.RegisterRoutes(app); await app.RunAsync();
DeveloperRoutes.RegisterRoutes(app); return 0;
app.Run();
} }
} }

View File

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

View File

@@ -1,28 +0,0 @@
using System.Net;
namespace Computernewb.CollabVMAuthServer;
public class User
{
public uint Id { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string Email { get; set; }
public DateOnly DateOfBirth { get; set; }
public bool EmailVerified { get; set; }
public string? EmailVerificationCode { get; set; }
public string? PasswordResetCode { get; set; }
public Rank Rank { get; set; }
public bool Banned { get; set; }
public string? BanReason { get; set; }
public IPAddress RegistrationIP { get; set; }
public DateTime Joined { get; set; }
public bool Developer { get; set; }
}
public enum Rank : uint
{
Registered = 1,
Admin = 2,
Moderator = 3,
}

View File

@@ -1,71 +1,20 @@
using System.Net; using System;
using System.Text; using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
public enum LogLevel
{
DEBUG,
INFO,
WARN,
ERROR,
FATAL
}
public static class Utilities public static class Utilities
{ {
public static JsonSerializerOptions JsonSerializerOptions => new JsonSerializerOptions public static void ConfigureLogging(ILoggingBuilder builder) {
{ builder.ClearProviders();
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull builder.AddConsole();
}; #if DEBUG
public static void Log(LogLevel level, string msg) builder.SetMinimumLevel(LogLevel.Debug);
{ #else
#if !DEBUG builder.SetMinimumLevel(LogLevel.Warning);
if (level == LogLevel.DEBUG)
return;
#endif #endif
StringBuilder logstr = new StringBuilder();
logstr.Append("[");
logstr.Append(DateTime.Now.ToString("G"));
logstr.Append("] [");
switch (level)
{
case LogLevel.DEBUG:
logstr.Append("DEBUG");
break;
case LogLevel.INFO:
logstr.Append("INFO");
break;
case LogLevel.WARN:
logstr.Append("WARN");
break;
case LogLevel.ERROR:
logstr.Append("ERROR");
break;
case LogLevel.FATAL:
logstr.Append("FATAL");
break;
default:
throw new ArgumentException("Invalid log level");
}
logstr.Append("] ");
logstr.Append(msg);
switch (level)
{
case LogLevel.DEBUG:
case LogLevel.INFO:
Console.WriteLine(logstr.ToString());
break;
case LogLevel.WARN:
case LogLevel.ERROR:
case LogLevel.FATAL:
Console.Error.WriteLine(logstr.ToString());
break;
}
} }
public static bool ValidateUsername(string username) public static bool ValidateUsername(string username)
@@ -97,32 +46,4 @@ public static class Utilities
} }
return str.ToString(); 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;
}
public static bool IsSessionExpired(Session session)
{
return DateTime.Now > session.LastUsed.AddDays(Program.Config.Accounts.SessionExpiryDays);
}
} }

View File

@@ -1,6 +1,9 @@
using System.Text.Json; using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata; using System.Threading.Tasks;
namespace Computernewb.CollabVMAuthServer; namespace Computernewb.CollabVMAuthServer;
@@ -33,8 +36,8 @@ public class hCaptchaClient
public class hCaptchaResponse public class hCaptchaResponse
{ {
public bool success { get; set; } public bool success { get; set; }
public string challenge_ts { get; set; } public string? challenge_ts { get; set; }
public string hostname { get; set; } public string? hostname { get; set; }
public bool? credit { get; set; } public bool? credit { get; set; }
[JsonPropertyName("error-codes")] [JsonPropertyName("error-codes")]
public string[]? error_codes { get; set; } public string[]? error_codes { get; set; }

View File

@@ -7,12 +7,22 @@ This is the authentication server for CollabVM. It is used alongside CollabVM se
- An SMTP server if you want email verification and password reset - An SMTP server if you want email verification and password reset
- An hCaptcha account if you want to use hCaptcha to prevent bots - An hCaptcha account if you want to use hCaptcha to prevent bots
## Running the server ## Building the server
1. Clone the source code: `git clone https://git.computernewb.com/collabvm/CollabVMAuthServer --recursive` 1. Clone the source code: `git clone https://git.computernewb.com/collabvm/CollabVMAuthServer --recursive`
2. Copy `config.example.toml` to `config.toml` and edit it to your liking 2. Copy `config.example.toml` to `config.toml` and edit it to your liking
3. Install dependencies: `dotnet restore` 3. Install dependencies: `dotnet restore`
4. Build the server: `dotnet publish CollabVMAuthServer/CollabVMAuthServer.csproj -c Release --os linux -p:PublishReadyToRun=true` 4. Build the server: `dotnet publish CollabVMAuthServer/CollabVMAuthServer.csproj -c Release --os linux -p:PublishReadyToRun=true`
5. Run the server: `./CollabVMAuthServer/bin/Release/net8.0/linux-x64/publish/CollabVMAuthServer`
## Run Database Migrations
You must do this the first time you run the server, and every time the database schema is updated (you will receive an error otherwise)
```
./CollabVMAuthServer/bin/Release/net8.0/linux-x64/publish/CollabVMAuthServer migrate-db
```
## Running the server
```
./CollabVMAuthServer/bin/Release/net8.0/linux-x64/publish/CollabVMAuthServer
```
## Setting up NGINX ## Setting up NGINX
You'll want to set up NGINX as a reverse proxy to the authentication server to add HTTPS support. Running the server over plain HTTP is strongly discouraged. Here is an example NGINX configuration: You'll want to set up NGINX as a reverse proxy to the authentication server to add HTTPS support. Running the server over plain HTTP is strongly discouraged. Here is an example NGINX configuration: