module api_modules.auth; import handy_httpd; import handy_httpd.components.optional; import handy_httpd.handlers.path_handler; import slf4d; import d2sqlite3; import std.algorithm : map; import std.array : array; import std.json; 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; } private struct UserResponse { ulong id; string username; ulong createdAt; bool isLocked; bool isAdmin; } 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) { 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]; auto passwordHash = encodePassword(decoded[idx+1 .. $]); Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username); 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. ) ) { return Optional!User.empty; } return optUser; } /** * 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. */ User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) { Optional!User optUser = getUserFromBasicAuth(ctx, db); if (optUser.isNull) { throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); } return optUser.value; } /** * 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) { Database db = getDb(); Optional!User optUser = getUserFromBasicAuth(ctx, db); 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 )); } 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); } }