2024-12-16 22:20:15 +00:00
|
|
|
module api_modules.auth;
|
|
|
|
|
|
|
|
import handy_httpd;
|
|
|
|
import handy_httpd.components.optional;
|
2025-01-22 17:55:14 +00:00
|
|
|
import handy_httpd.handlers.path_handler;
|
2024-12-16 22:20:15 +00:00
|
|
|
import slf4d;
|
|
|
|
import d2sqlite3;
|
2025-01-22 17:55:14 +00:00
|
|
|
import std.algorithm : map;
|
|
|
|
import std.array : array;
|
|
|
|
import std.json;
|
2024-12-16 22:20:15 +00:00
|
|
|
|
|
|
|
import db;
|
|
|
|
import data_utils;
|
|
|
|
|
|
|
|
struct User {
|
|
|
|
const ulong id;
|
|
|
|
const string username;
|
|
|
|
const string passwordHash;
|
|
|
|
const ulong createdAt;
|
|
|
|
const bool isLocked;
|
|
|
|
const bool isAdmin;
|
|
|
|
}
|
|
|
|
|
2024-12-17 03:22:56 +00:00
|
|
|
private struct UserResponse {
|
2024-12-16 22:20:15 +00:00
|
|
|
ulong id;
|
|
|
|
string username;
|
|
|
|
ulong createdAt;
|
|
|
|
bool isLocked;
|
|
|
|
bool isAdmin;
|
|
|
|
}
|
|
|
|
|
2025-01-22 17:55:14 +00:00
|
|
|
void registerApiEndpoints(PathHandler handler) {
|
|
|
|
handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint);
|
|
|
|
handler.addMapping(Method.GET, "/api/auth/admin/users", &usersAdminEndpoint);
|
|
|
|
handler.addMapping(Method.POST, "/api/auth/admin/users", &createUserAdminEndpoint);
|
|
|
|
handler.addMapping(Method.DELETE, "/api/auth/admin/users/:userId:ulong", &deleteUserAdminEndpoint);
|
|
|
|
handler.addMapping(Method.PUT, "/api/auth/admin/users/:userId:ulong", &updateUserAdminEndpoint);
|
|
|
|
}
|
|
|
|
|
|
|
|
private string encodePassword(string password) {
|
|
|
|
import std.digest.sha;
|
|
|
|
return toHexString(sha256Of(password)).idup;
|
|
|
|
}
|
|
|
|
|
|
|
|
private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Database db) {
|
2024-12-16 22:20:15 +00:00
|
|
|
import std.base64;
|
|
|
|
import std.string : startsWith;
|
|
|
|
import std.digest.sha;
|
|
|
|
import std.algorithm : countUntil;
|
|
|
|
|
|
|
|
string headerStr = ctx.request.headers.getFirst("Authorization").orElse("");
|
|
|
|
if (headerStr.length == 0 || !startsWith(headerStr, "Basic ")) {
|
|
|
|
return Optional!User.empty;
|
|
|
|
}
|
|
|
|
string encodedCredentials = headerStr[6..$];
|
|
|
|
string decoded = cast(string) Base64.decode(encodedCredentials);
|
|
|
|
size_t idx = countUntil(decoded, ':');
|
|
|
|
string username = decoded[0..idx];
|
2025-01-22 17:55:14 +00:00
|
|
|
auto passwordHash = encodePassword(decoded[idx+1 .. $]);
|
2024-12-16 22:20:15 +00:00
|
|
|
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
|
2025-01-22 17:55:14 +00:00
|
|
|
if ( // Reject the user's authentication, even if they exist, if:
|
|
|
|
!optUser.isNull &&
|
|
|
|
(
|
|
|
|
optUser.value.passwordHash != passwordHash || // Password doesn't match.
|
|
|
|
optUser.value.isLocked // Account is locked.
|
|
|
|
)
|
|
|
|
) {
|
2024-12-16 22:20:15 +00:00
|
|
|
return Optional!User.empty;
|
|
|
|
}
|
|
|
|
return optUser;
|
|
|
|
}
|
|
|
|
|
2025-01-22 17:55:14 +00:00
|
|
|
/**
|
|
|
|
* Gets the current user for a given request context, based on their basic
|
|
|
|
* authentication header.
|
|
|
|
* Params:
|
|
|
|
* ctx = The request context.
|
|
|
|
* db = The database to query.
|
|
|
|
* Returns: The user that made the request. Otherwise, a 401 is thrown.
|
|
|
|
*/
|
2024-12-28 05:00:10 +00:00
|
|
|
User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
|
2025-01-22 17:55:14 +00:00
|
|
|
Optional!User optUser = getUserFromBasicAuth(ctx, db);
|
2024-12-16 22:20:15 +00:00
|
|
|
if (optUser.isNull) {
|
|
|
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
|
|
|
}
|
|
|
|
return optUser.value;
|
|
|
|
}
|
|
|
|
|
2025-01-22 17:55:14 +00:00
|
|
|
/**
|
|
|
|
* Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
|
|
|
|
* Params:
|
|
|
|
* ctx = The request context.
|
|
|
|
* db = The database to query.
|
|
|
|
* Returns: The user that made the request.
|
|
|
|
*/
|
|
|
|
User getAdminUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
|
|
|
|
User user = getUserOrThrow(ctx, db);
|
|
|
|
if (!user.isAdmin) {
|
|
|
|
throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
|
|
|
|
}
|
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
|
|
|
private void loginEndpoint(ref HttpRequestContext ctx) {
|
2024-12-28 05:00:10 +00:00
|
|
|
Database db = getDb();
|
2025-01-22 17:55:14 +00:00
|
|
|
Optional!User optUser = getUserFromBasicAuth(ctx, db);
|
2024-12-16 22:20:15 +00:00
|
|
|
if (optUser.isNull) {
|
|
|
|
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
|
|
|
ctx.response.writeBodyString("Invalid credentials.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
infoF!"Login successful for user \"%s\"."(optUser.value.username);
|
|
|
|
writeJsonBody(ctx, UserResponse(
|
|
|
|
optUser.value.id,
|
|
|
|
optUser.value.username,
|
|
|
|
optUser.value.createdAt,
|
|
|
|
optUser.value.isLocked,
|
|
|
|
optUser.value.isAdmin
|
|
|
|
));
|
|
|
|
}
|
2025-01-22 17:55:14 +00:00
|
|
|
|
|
|
|
private void usersAdminEndpoint(ref HttpRequestContext ctx) {
|
|
|
|
Database db = getDb();
|
|
|
|
User user = getAdminUserOrThrow(ctx, db);
|
|
|
|
uint page = ctx.request.getParamAs!uint("page", 0);
|
|
|
|
uint pageSize = ctx.request.getParamAs!uint("size", 30);
|
|
|
|
if (page < 0) page = 0;
|
|
|
|
if (pageSize < 5) pageSize = 5;
|
|
|
|
if (pageSize > 100) pageSize = 100;
|
|
|
|
uint offset = page * pageSize;
|
|
|
|
|
|
|
|
const query = "SELECT * FROM user ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
|
|
|
UserResponse[] users = findAll!(User)(db, query, pageSize, offset)
|
|
|
|
.map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
|
|
|
|
.array;
|
|
|
|
writeJsonBody(ctx, users);
|
|
|
|
}
|
|
|
|
|
|
|
|
private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
|
|
|
|
Database db = getDb();
|
|
|
|
User user = getAdminUserOrThrow(ctx, db);
|
|
|
|
struct Payload {
|
|
|
|
string username;
|
|
|
|
string password;
|
|
|
|
}
|
|
|
|
Payload payload = readJsonPayload!(Payload)(ctx);
|
|
|
|
// TODO: Validate data
|
|
|
|
string passwordHash = encodePassword(payload.password);
|
|
|
|
const query = "
|
|
|
|
INSERT INTO user (
|
|
|
|
username,
|
|
|
|
password_hash,
|
|
|
|
created_at,
|
|
|
|
is_locked,
|
|
|
|
is_admin
|
|
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
|
|
";
|
|
|
|
db.execute(query, payload.username, passwordHash, getUnixTimestampMillis(), false, false);
|
|
|
|
ulong newUserId = db.lastInsertRowid();
|
|
|
|
User newUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", newUserId).orElseThrow();
|
|
|
|
writeJsonBody(ctx, UserResponse(
|
|
|
|
newUser.id,
|
|
|
|
newUser.username,
|
|
|
|
newUser.createdAt,
|
|
|
|
newUser.isLocked,
|
|
|
|
newUser.isAdmin
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
|
|
|
|
Database db = getDb();
|
|
|
|
User user = getAdminUserOrThrow(ctx, db);
|
|
|
|
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
|
|
|
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
|
|
|
|
if (!targetUser.isNull) {
|
|
|
|
db.execute("DELETE FROM user WHERE id = ?", targetUserId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
|
|
|
Database db = getDb();
|
|
|
|
User user = getAdminUserOrThrow(ctx, db);
|
|
|
|
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
|
|
|
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
|
|
|
|
if (targetUser.isNull) {
|
|
|
|
ctx.response.status = HttpStatus.NOT_FOUND;
|
|
|
|
ctx.response.writeBodyString("User not found.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
JSONValue payload = ctx.request.readBodyAsJson();
|
|
|
|
if (payload.type != JSONType.object) {
|
|
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
|
|
ctx.response.writeBodyString("Expected JSON object with user properties.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
db.begin();
|
|
|
|
try {
|
|
|
|
if ("username" in payload.object) {
|
|
|
|
string newUsername = payload.object["username"].str;
|
|
|
|
if (newUsername != targetUser.value.username) {
|
|
|
|
if (newUsername.length < 3 || newUsername.length > 32) {
|
|
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
|
|
ctx.response.writeBodyString("Invalid username.");
|
|
|
|
db.rollback();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (canFind(db, "SELECT id FROM user WHERE username = ?", newUsername)) {
|
|
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
|
|
ctx.response.writeBodyString("Username already taken.");
|
|
|
|
db.rollback();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
db.execute("UPDATE user SET username = ? WHERE id = ?", newUsername, targetUserId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if ("password" in payload.object) {
|
|
|
|
string rawPassword = payload.object["password"].str;
|
|
|
|
string newPasswordHash = encodePassword(rawPassword);
|
|
|
|
if (newPasswordHash != targetUser.value.passwordHash) {
|
|
|
|
db.execute("UPDATE user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if ("isLocked" in payload.object) {
|
|
|
|
bool newIsLocked = payload.object["isLocked"].boolean;
|
|
|
|
if (newIsLocked != targetUser.value.isLocked) {
|
|
|
|
db.execute("UPDATE user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
db.commit();
|
|
|
|
User updatedUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId).orElseThrow();
|
|
|
|
writeJsonBody(ctx, UserResponse(
|
|
|
|
updatedUser.id,
|
|
|
|
updatedUser.username,
|
|
|
|
updatedUser.createdAt,
|
|
|
|
updatedUser.isLocked,
|
|
|
|
updatedUser.isAdmin
|
|
|
|
));
|
|
|
|
} catch (Exception e) {
|
|
|
|
db.rollback();
|
|
|
|
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
|
|
ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
|
|
|
|
}
|
|
|
|
}
|