module api_modules.auth; import handy_httpd; import handy_httpd.components.optional; import handy_httpd.handlers.path_handler; import slf4d; import std.algorithm : map; import std.array : array; import std.json; import ddbc; 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; static User parse(DataSetReader r) { return User( r.getUlong(1), r.getString(2), r.getString(3), r.getUlong(4), r.getBoolean(5), r.getBoolean(6) ); } } 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, Connection conn) { 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( conn, "SELECT * FROM auth_user WHERE username = ?", &User.parse, 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. * conn = The database connection. * Returns: The user that made the request. Otherwise, a 401 is thrown. */ User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) { Optional!User optUser = getUserFromBasicAuth(ctx, conn); 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. * conn = The database connection. * Returns: The user that made the request. */ User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) { User user = getUserOrThrow(ctx, conn); if (!user.isAdmin) { throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource."); } return user; } private void loginEndpoint(ref HttpRequestContext ctx) { Connection conn = getDb(); scope(exit) conn.close(); Optional!User optUser = getUserFromBasicAuth(ctx, conn); 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) { Connection conn = getDb(); scope(exit) conn.close(); User user = getAdminUserOrThrow(ctx, conn); 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 auth_user ORDER BY created_at DESC LIMIT ? OFFSET ?"; UserResponse[] users = findAll( conn, query, &User.parse, 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) { Connection conn = getDb(); scope(exit) conn.close(); User user = getAdminUserOrThrow(ctx, conn); struct Payload { string username; string password; } Payload payload = readJsonPayload!(Payload)(ctx); // TODO: Validate data string passwordHash = encodePassword(payload.password); const query = " INSERT INTO auth_user ( username, password_hash, is_locked, is_admin ) VALUES (?, ?, ?, ?) RETURNING id "; ulong newUserId = insertOne( conn, query, payload.username, passwordHash, false, false ); User newUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, newUserId).orElseThrow(); writeJsonBody(ctx, UserResponse( newUser.id, newUser.username, newUser.createdAt, newUser.isLocked, newUser.isAdmin )); } private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) { Connection conn = getDb(); scope(exit) conn.close(); User user = getAdminUserOrThrow(ctx, conn); ulong targetUserId = ctx.request.getPathParamAs!ulong("userId"); Optional!User targetUser = findOne( conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId ); if (!targetUser.isNull) { update(conn, "DELETE FROM auth_user WHERE id = ?", targetUserId); } } private void updateUserAdminEndpoint(ref HttpRequestContext ctx) { Connection conn = getDb(); scope(exit) conn.close(); conn.setAutoCommit(false); User user = getAdminUserOrThrow(ctx, conn); ulong targetUserId = ctx.request.getPathParamAs!ulong("userId"); Optional!User targetUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, 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; } 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."); conn.rollback(); return; } if (recordExists(conn, "SELECT id FROM auth_user WHERE username = ?", newUsername)) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Username already taken."); conn.rollback(); return; } update(conn, "UPDATE auth_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) { update(conn, "UPDATE auth_user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId); } } if ("isLocked" in payload.object) { bool newIsLocked = payload.object["isLocked"].boolean; if (newIsLocked != targetUser.value.isLocked) { update(conn, "UPDATE auth_user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId); } } conn.commit(); User updatedUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId) .orElseThrow(); writeJsonBody(ctx, UserResponse( updatedUser.id, updatedUser.username, updatedUser.createdAt, updatedUser.isLocked, updatedUser.isAdmin )); } catch (Exception e) { conn.rollback(); ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; ctx.response.writeBodyString("Something went wrong: " ~ e.msg); } }