diff --git a/.gitignore b/.gitignore
index d795194..3bc5ddf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,8 @@ compile_commands.json
CTestTestfile.cmake
_deps
CMakeUserPresets.json
-out/
\ No newline at end of file
+out/
+
+# Auth user store (holds password hashes) and its temporary file
+users.json
+users.json.tmp
\ No newline at end of file
diff --git a/README.md b/README.md
index aed6190..2242bfc 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ file uploads (`multipart/form-data`).
- [`server.maxBody(bytes)`](#servermaxbodybytes)
- [File uploads](#file-uploads)
- [`http.save` / `http.saveTo`](#httpsave--httpsaveto)
+ - [Authentication (`auth.*`)](#authentication-auth)
- [`view.Render(path, vars)`](#viewrenderpath-vars)
- [Status codes](#status-codes)
- [Request and response flow](#request-and-response-flow)
@@ -51,6 +52,7 @@ file uploads (`multipart/form-data`).
| Request parsing | Query string, url-encoded bodies, JSON bodies and `multipart/form-data` |
| File uploads | Uploaded files as tables (`filename`, `contentType`, `size`, raw `content`) plus `http.save` / `http.saveTo` helpers to write them to disk |
| Cookies | Parsed `Cookie:` header as a table |
+| Authentication | HTTP Basic auth for the paths you choose, with PBKDF2-HMAC-SHA256 hashed passwords in `users.json` |
| Error pages | Custom handlers or bodies per status code, with a built-in HTML fallback |
| Templates | `view.Render("page.html", { ... })` with recursive `@include` and `{{ key }}` interpolation |
| Body limit | Configurable request size limit (`server.maxBody`), oversized uploads get `413` |
@@ -118,7 +120,7 @@ Load it from a script with `require` (the returned string is a status message su
require("dlang.http");
```
-Once loaded, the plugin registers two globals: `http` and `view`.
+Once loaded, the plugin registers three globals: `http`, `view` and `auth`.
---
@@ -129,6 +131,10 @@ Once loaded, the plugin registers two globals: `http` and `view`.
```dlang
require("dlang.http");
+// Basic auth: hash the password into ./users.json and gate /admin/**
+auth.addUser("admin", "geheim");
+auth.protectPrefix("/admin");
+
var server = http.server();
// Serve everything in ./public under /assets (e.g. /assets/css/site.css)
@@ -217,7 +223,7 @@ func(req, params, cookies, files)
| Argument | Contents |
| --- | --- |
-| `req` | Table with `method`, `path`, `query` (only when the URL had one), `params`, `cookies`, `files` and `body` (only when the request body was not empty) |
+| `req` | Table with `method`, `path`, `query` (only when the URL had one), `headers`, `params`, `cookies`, `files`, `body` (only when the request body was not empty) and `user` (only when the request passed an [`auth`](#authentication-auth) gate) |
| `params` | All request parameters merged into one table: query string + body |
| `cookies` | The parsed `Cookie:` header |
| `files` | Uploaded files from a `multipart/form-data` body, keyed by form field name |
@@ -233,6 +239,10 @@ func(req, params, cookies, files)
A key that occurs more than once (for example `?tag=a&tag=b`) becomes a list.
+`req.headers` holds every request header with its name lower-cased, so
+`req.headers.authorization` or `req.headers.content-type` can be read directly; a header
+that occurs more than once becomes a list too.
+
The four-argument signature is backward compatible: older three-parameter handlers keep
working, they simply never see `files`.
@@ -352,6 +362,95 @@ the same name.
`../../etc/passwd`) is stripped and unsafe characters are replaced, so a client can never
choose the target directory.
+### Authentication (`auth.*`)
+
+Optional HTTP Basic auth. You choose which paths are protected; everything else stays open.
+
+Passwords are **hashed, not encrypted**: the plugin generates a random salt per account and
+stores a PBKDF2-HMAC-SHA256 hash of the password, so the file never holds anything that can
+be turned back into the original password.
+
+```dlang
+require("dlang.http");
+
+auth.addUser("admin", "geheim"); // hashes the password and writes ./users.json
+auth.realm("My site"); // the text shown in the browser's login popup
+auth.protectPrefix("/admin"); // /admin and everything below need credentials
+
+var server = http.server();
+
+server.route("/admin", ["GET"], func(req, params, cookies, files)
+ return "
Welcome back, " + req.user + "
";
+end);
+
+server.listen(8080);
+```
+
+The first request for a protected path is answered with `401 Unauthorized` plus a
+`WWW-Authenticate: Basic realm="..."` header. A browser reacts to that header by showing its
+own username/password dialog; once the visitor fills it in, the request is repeated with an
+`Authorization: Basic ...` header and the page is served. Inside the handler `req.user` holds
+the authenticated name.
+
+The user file lives in the working directory (`users.json` by default) — add it to your
+`.gitignore`, since it holds password hashes.
+
+#### The user store
+
+| Call | Description |
+| --- | --- |
+| `auth.file(path)` | Use another user file; returns the path in use. Default: `users.json`. |
+| `auth.reload()` | Re-read the file from disk (after editing it by hand). `true` on success. |
+| `auth.addUser(name, password)` | Create the account or replace its password. Writes the file and returns `true`, or `false` when the name/password is rejected or the file cannot be written. |
+| `auth.removeUser(name)` | Delete the account. `true` when it existed and the file was written. |
+| `auth.hasUser(name)` | `true` when the account exists. |
+| `auth.users()` | List of user names. |
+| `auth.verify(name, password)` | `true` when the password matches — to check credentials inside your own handler. |
+| `auth.login(req)` | The name from the request's `Authorization` header, or `""`. |
+| `auth.iterations(count)` | PBKDF2 rounds for new or updated passwords (default `100000`, clamped to `1000`–`1000000`); returns the value in use. |
+| `auth.realm(name)` | The realm in the login popup (default `Restricted`); returns the value in use. |
+
+A name may not be empty or contain `:`, whitespace or control characters (it is sent as
+`name:password`), and the password may not be empty. `addUser` never overwrites a
+`users.json` it cannot parse — it reports the problem on `stderr` instead, so a hand-edited
+file with a typo cannot silently wipe your accounts.
+
+The file looks like this (`salt` and `hash` are hex; `iterations` is stored per account so
+the cost can be raised later without invalidating existing passwords):
+
+```json
+{
+ "algorithm": "pbkdf2-sha256",
+ "users": [
+ { "username": "admin", "salt": "ca63c036f511d4006c93ad68353677d7",
+ "hash": "d4b7284ab9668ecbb548f8fd8048b44200368b258cd2b87d00807737c9aec472",
+ "iterations": 100000 }
+ ]
+}
+```
+
+Accounts are read lazily, so `auth.addUser(...)` can simply run just before
+`server.listen(...)`. A password that verified once is remembered in memory (as a hash of the
+password, never the password itself), so the deliberately slow PBKDF2 work is paid for the
+first request of a session instead of every request. Editing the file by hand? Call
+`auth.reload()`.
+
+#### Protecting paths
+
+| Call | Description |
+| --- | --- |
+| `auth.protect(path)` | Require credentials for exactly this path. |
+| `auth.protectPrefix(path)` | Require credentials for this path and everything below it. |
+| `auth.unprotect(path)` | Remove an exact-path protection again. |
+| `auth.unprotectPrefix(path)` | Remove a prefix protection again. |
+
+Paths are normalised (`admin/panel/` becomes `/admin/panel`) and `auth.protectPrefix("/")`
+gates the whole site. The check happens before routing, static files and error pages, so a
+protected path never leaks whether it exists: `/secret/nope` answers `401` without
+credentials and `404` with them. The `401` body comes from
+[`server.error(401, ...)`](#servererrorcode-handler) like any other status code; only the
+`WWW-Authenticate` header is added by the plugin.
+
### `view.Render(path, vars)`
Renders a small template file and returns the result as a string. `view.render` is an alias.
@@ -403,7 +502,9 @@ flowchart TD
A[Accept connection] --> B[Read headers + Content-Length body]
B --> C{Body within limit?}
C -- no --> D[413]
- C -- yes --> E[Parse query, cookies and body
urlencoded / JSON / multipart]
+ C -- yes --> N{Protected path?}
+ N -- "wrong or missing credentials" --> O[401 + WWW-Authenticate]
+ N -- "not protected, or valid credentials" --> E[Parse query, headers, cookies and body
urlencoded / JSON / multipart]
E --> F{Route matches path?}
F -- yes, method allowed --> G[Call handler with req, params, cookies, files]
F -- yes, other method --> H[405]
@@ -427,8 +528,14 @@ flowchart TD
- **In-memory bodies.** The whole body (up to `server.maxBody`) is buffered before a handler
runs, and static files are read fully into memory before being sent.
- **HTTP/1.1 only**, no TLS. Put a reverse proxy in front if you need HTTPS.
-- **Global server state.** Routes, static mounts, error pages and the socket are
- process-wide, so one `listen` call serves one server per process.
+- **Basic auth is only as safe as the transport.** The browser resends the password with
+ every request (base64 encoded, not encrypted), so use it behind HTTPS — or keep it on a
+ trusted network.
+- **A wrong password costs a full PBKDF2 run.** `auth.iterations` defaults to 100 000, and
+ the server handles one request at a time, so an attacker can keep it busy with bad
+ credentials. Lower the iteration count or add rate limiting if that matters to you.
+- **Global server state.** Routes, static mounts, error pages, the protected paths and the
+ loaded accounts are process-wide, so one `listen` call serves one server per process.
---
@@ -441,7 +548,7 @@ dlang-http/
└─ dlang.http/
├─ CMakeLists.txt # builds the `dlang.http` shared library
├─ entry.cpp # the entire plugin: server, routing, static files,
- │ # uploads, error pages and the view engine
+ │ # uploads, error pages, Basic auth and the view engine
├─ json.hpp # nlohmann/json (used by the VM's table library)
└─ dlang/ # upstream dlang VM copy — do not edit
├─ vm.hpp, types/, libs/, parser/
@@ -460,8 +567,9 @@ This repository and this README were produced with the help of an AI assistant
(GitHub Copilot). That means:
- The code was **generated, then verified against its own test harnesses** (request parsing,
- multipart uploads, socket-level request handling, `http.save`/`saveTo`), but it has not
- been reviewed by a human maintainer.
+ multipart uploads, socket-level request handling, `http.save`/`saveTo`, and the auth
+ module including its SHA-256/HMAC/PBKDF2 test vectors), but it has not been reviewed by a
+ human maintainer.
- Comments and documentation may describe behaviour that has not been audited in every
edge case, and the API is still evolving.
- Treat it as a starting point: review the source in `dlang.http/entry.cpp`, test it with
diff --git a/dlang.http/entry.cpp b/dlang.http/entry.cpp
index 95b5546..f4022e1 100644
--- a/dlang.http/entry.cpp
+++ b/dlang.http/entry.cpp
@@ -1,4 +1,12 @@
-#if defined(_WIN32) || defined(_WIN64)
+// rand_s() — a cryptographically strong RNG on Windows — must be requested
+// before (pulled in by below) is first included, so this
+// macro has to live at the very top of the file. On Linux the auth code reads
+// /dev/urandom instead.
+#if defined(_MSC_VER) && !defined(_CRT_RAND_S)
+ #define _CRT_RAND_S
+#endif
+
+#if defined(_WIN32) || defined(_WIN64)
#define DLANG_HTTP_WINDOWS
#elif defined(__linux__)
#define DLANG_HTTP_LINUX
@@ -25,7 +33,9 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -262,10 +272,14 @@ static bool ReadFileBinary(const std::string& path, std::string& out) {
return true;
}
+// `extraHeaders` holds additional response headers, each already terminated
+// with CRLF (used for `WWW-Authenticate` on a 401 and `Set-Cookie`).
static void SendResponse(SocketHandle sock, int statusCode,
- const std::string& contentType, const std::string& body) {
+ const std::string& contentType, const std::string& body,
+ const std::string& extraHeaders = std::string()) {
std::ostringstream resp;
resp << "HTTP/1.1 " << statusCode << " " << ReasonPhrase(statusCode) << "\r\n"
+ << extraHeaders
<< "Content-Type: " << contentType << "\r\n"
<< "Content-Length: " << body.length() << "\r\n"
<< "Connection: close\r\n\r\n"
@@ -916,7 +930,11 @@ void NativeViewRender(DlangVM* vm) {
//
// fn(req, params, cookies, files)
//
-// req -> { method, path, query, params, cookies, files, body }
+// req -> { method, path, query, headers, params, cookies, files, body,
+// user }
+// `headers` holds every request header (names lower-cased) and
+// `user` is only present for a request that passed the HTTP Basic
+// gate set up with auth.protect(...) / auth.protectPrefix(...).
// params -> a single key/value table holding *all* request parameters:
// the query string (GET) plus the request body (POST/PUT/PATCH/
// DELETE). Body values are read as url-encoded form data, as a
@@ -1074,6 +1092,35 @@ static void ParseCookies(const std::string& header, DlangTable& table) {
}
}
+// Parse a header block (request line followed by `Name: value` lines) into a
+// table. Header names are lower-cased; a header that occurs more than once is
+// collected into a list, exactly like a repeated query parameter.
+static void ParseHeaders(const std::string& headerBlock, DlangTable& table) {
+ std::istringstream stream(headerBlock);
+ std::string line;
+ bool firstLine = true;
+
+ while (std::getline(stream, line)) {
+ if (firstLine) {
+ firstLine = false; // the request line is not a header
+ continue;
+ }
+
+ if (!line.empty() && line.back() == '\r')
+ line.pop_back();
+ if (line.empty())
+ continue;
+
+ size_t colon = line.find(':');
+ if (colon == std::string::npos)
+ continue;
+
+ std::string name = ToLower(HttpTrim(line.substr(0, colon)));
+ if (!name.empty())
+ AddParamValue(table, name, HttpTrim(line.substr(colon + 1)));
+ }
+}
+
// Decode a JSON string into a value using the VM's native `table.json_decode`.
// Returns false when the library/function is unavailable or the text is not
// valid JSON, in which case the body is simply ignored.
@@ -1466,6 +1513,1042 @@ void NativeFilesSaveTo(DlangVM* vm) {
dlang_pushstring(vm, target.string());
}
+// Read a full HTTP request: the header block plus the body described by
+// Content-Length. `headerEnd` is set to the offset of the blank line that
+// terminates the headers. Returns false when no complete header block arrived.
+// `outTooLarge` reports a body bigger than `g_maxBodyBytes`, in which case the
+// request is refused before the whole body has been buffered.
+// ---------------------------------------------------------------------------
+// Authentication: HTTP Basic auth with hashed passwords
+//
+// Passwords never reach the disk in the clear. Every account gets a random salt
+// and a PBKDF2-HMAC-SHA256 hash, stored in a JSON file (`users.json` in the
+// process working directory by default):
+//
+// {
+// "algorithm": "pbkdf2-sha256",
+// "users": [
+// { "username": "admin", "salt": "…hex…", "hash": "…hex…",
+// "iterations": 100000 }
+// ]
+// }
+//
+// The dlang side configures everything through the `auth` global:
+//
+// auth.addUser("admin", "geheim"); // hashes the password, writes users.json
+// auth.protectPrefix("/admin"); // HTTP Basic gate for /admin and below
+// auth.realm("My site"); // text in the browser's login popup
+//
+// A request for a protected path must carry `Authorization: Basic base64(user:password)`.
+// Without valid credentials it is answered with 401 + `WWW-Authenticate`, which is
+// what makes the browser show its own username/password dialog. Handlers receive the
+// authenticated name as `req.user`, and can also check credentials themselves with
+// `auth.login(req)` or `auth.verify(name, password)`.
+//
+// CAUTION: Basic auth resends the password (base64 encoded, i.e. readable) with every
+// request, so it is only safe behind HTTPS — put a TLS reverse proxy in front.
+// ---------------------------------------------------------------------------
+
+// --- SHA-256 (FIPS 180-4) --------------------------------------------------
+
+// First 32 bits of the fractional parts of the cube roots of the first 64 primes.
+static const uint32_t kSha256RoundKeys[64] = {
+ 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u,
+ 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
+ 0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u,
+ 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
+ 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu,
+ 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
+ 0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u,
+ 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
+ 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u,
+ 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
+ 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u,
+ 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
+ 0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u,
+ 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
+ 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u,
+ 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u
+};
+
+struct Sha256State {
+ uint32_t hash[8];
+ unsigned char block[64];
+ size_t blockLength;
+ uint64_t totalBytes;
+};
+
+static uint32_t RotateRight(uint32_t value, unsigned int bits) {
+ return (value >> bits) | (value << (32u - bits));
+}
+
+static void Sha256Init(Sha256State& state) {
+ // First 32 bits of the fractional parts of the square roots of the first
+ // eight primes.
+ state.hash[0] = 0x6a09e667u;
+ state.hash[1] = 0xbb67ae85u;
+ state.hash[2] = 0x3c6ef372u;
+ state.hash[3] = 0xa54ff53au;
+ state.hash[4] = 0x510e527fu;
+ state.hash[5] = 0x9b05688cu;
+ state.hash[6] = 0x1f83d9abu;
+ state.hash[7] = 0x5be0cd19u;
+ state.blockLength = 0;
+ state.totalBytes = 0;
+}
+
+static void Sha256Compress(Sha256State& state, const unsigned char* block) {
+ uint32_t schedule[64];
+
+ for (int i = 0; i < 16; ++i) {
+ schedule[i] = (static_cast(block[i * 4]) << 24) |
+ (static_cast(block[i * 4 + 1]) << 16) |
+ (static_cast(block[i * 4 + 2]) << 8) |
+ static_cast(block[i * 4 + 3]);
+ }
+
+ for (int i = 16; i < 64; ++i) {
+ uint32_t s0 = RotateRight(schedule[i - 15], 7) ^
+ RotateRight(schedule[i - 15], 18) ^
+ (schedule[i - 15] >> 3);
+ uint32_t s1 = RotateRight(schedule[i - 2], 17) ^
+ RotateRight(schedule[i - 2], 19) ^
+ (schedule[i - 2] >> 10);
+
+ schedule[i] = schedule[i - 16] + s0 + schedule[i - 7] + s1;
+ }
+
+ uint32_t a = state.hash[0], b = state.hash[1], c = state.hash[2], d = state.hash[3];
+ uint32_t e = state.hash[4], f = state.hash[5], g = state.hash[6], h = state.hash[7];
+
+ for (int i = 0; i < 64; ++i) {
+ uint32_t s1 = RotateRight(e, 6) ^ RotateRight(e, 11) ^ RotateRight(e, 25);
+ uint32_t choose = (e & f) ^ (~e & g);
+ uint32_t temp1 = h + s1 + choose + kSha256RoundKeys[i] + schedule[i];
+ uint32_t s0 = RotateRight(a, 2) ^ RotateRight(a, 13) ^ RotateRight(a, 22);
+ uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
+ uint32_t temp2 = s0 + majority;
+
+ h = g;
+ g = f;
+ f = e;
+ e = d + temp1;
+ d = c;
+ c = b;
+ b = a;
+ a = temp1 + temp2;
+ }
+
+ state.hash[0] += a;
+ state.hash[1] += b;
+ state.hash[2] += c;
+ state.hash[3] += d;
+ state.hash[4] += e;
+ state.hash[5] += f;
+ state.hash[6] += g;
+ state.hash[7] += h;
+}
+
+static void Sha256Update(Sha256State& state, const unsigned char* data, size_t length) {
+ state.totalBytes += length;
+
+ for (size_t i = 0; i < length; ++i) {
+ state.block[state.blockLength++] = data[i];
+
+ if (state.blockLength == sizeof(state.block)) {
+ Sha256Compress(state, state.block);
+ state.blockLength = 0;
+ }
+ }
+}
+
+static void Sha256Final(Sha256State& state, unsigned char out[32]) {
+ uint64_t bitLength = state.totalBytes * 8;
+
+ // One 0x80 byte, then zero padding up to 56 bytes, then the bit length.
+ state.block[state.blockLength++] = 0x80;
+
+ if (state.blockLength > 56) {
+ while (state.blockLength < sizeof(state.block))
+ state.block[state.blockLength++] = 0x00;
+
+ Sha256Compress(state, state.block);
+ state.blockLength = 0;
+ }
+
+ while (state.blockLength < 56)
+ state.block[state.blockLength++] = 0x00;
+
+ for (int i = 7; i >= 0; --i)
+ state.block[56 + (7 - i)] = static_cast((bitLength >> (i * 8)) & 0xff);
+
+ Sha256Compress(state, state.block);
+ state.blockLength = 0;
+
+ for (int i = 0; i < 8; ++i) {
+ out[i * 4] = static_cast((state.hash[i] >> 24) & 0xff);
+ out[i * 4 + 1] = static_cast((state.hash[i] >> 16) & 0xff);
+ out[i * 4 + 2] = static_cast((state.hash[i] >> 8) & 0xff);
+ out[i * 4 + 3] = static_cast(state.hash[i] & 0xff);
+ }
+}
+
+static void Sha256(const unsigned char* data, size_t length, unsigned char out[32]) {
+ Sha256State state;
+ Sha256Init(state);
+ Sha256Update(state, data, length);
+ Sha256Final(state, out);
+}
+
+static void Sha256String(const std::string& text, unsigned char out[32]) {
+ Sha256(reinterpret_cast(text.data()), text.size(), out);
+}
+
+// --- HMAC-SHA256 (RFC 2104) & PBKDF2 (RFC 8018) ----------------------------
+
+// The padded key is derived once so that PBKDF2 does not re-hash it for every
+// one of its (many) rounds.
+struct HmacSha256Key {
+ unsigned char innerPad[64];
+ unsigned char outerPad[64];
+};
+
+static void PrepareHmacSha256Key(const std::string& key, HmacSha256Key& out) {
+ unsigned char digested[32];
+ const unsigned char* material = reinterpret_cast(key.data());
+ size_t materialLength = key.size();
+
+ if (materialLength > sizeof(out.innerPad)) {
+ Sha256(material, materialLength, digested);
+ material = digested;
+ materialLength = sizeof(digested);
+ }
+
+ for (size_t i = 0; i < sizeof(out.innerPad); ++i) {
+ unsigned char byte = (i < materialLength) ? material[i] : 0x00;
+ out.innerPad[i] = static_cast(byte ^ 0x36);
+ out.outerPad[i] = static_cast(byte ^ 0x5c);
+ }
+}
+
+static void HmacSha256(const HmacSha256Key& key, const unsigned char* message,
+ size_t length, unsigned char out[32]) {
+ unsigned char inner[32];
+ Sha256State state;
+
+ Sha256Init(state);
+ Sha256Update(state, key.innerPad, sizeof(key.innerPad));
+ Sha256Update(state, message, length);
+ Sha256Final(state, inner);
+
+ Sha256Init(state);
+ Sha256Update(state, key.outerPad, sizeof(key.outerPad));
+ Sha256Update(state, inner, sizeof(inner));
+ Sha256Final(state, out);
+}
+
+// PBKDF2-HMAC-SHA256 with a 32 byte derived key, so exactly one output block.
+static void Pbkdf2Sha256(const std::string& password,
+ const std::vector& salt,
+ int iterations, unsigned char out[32]) {
+ HmacSha256Key key;
+ PrepareHmacSha256Key(password, key);
+
+ std::vector firstInput(salt);
+ firstInput.push_back(0x00); // INT_32_BE(1)
+ firstInput.push_back(0x00);
+ firstInput.push_back(0x00);
+ firstInput.push_back(0x01);
+
+ unsigned char current[32];
+ HmacSha256(key, firstInput.data(), firstInput.size(), current);
+
+ unsigned char accumulated[32];
+ std::copy(current, current + sizeof(current), accumulated);
+
+ for (int round = 1; round < iterations; ++round) {
+ HmacSha256(key, current, sizeof(current), current);
+
+ for (size_t i = 0; i < sizeof(accumulated); ++i)
+ accumulated[i] = static_cast(accumulated[i] ^ current[i]);
+ }
+
+ std::copy(accumulated, accumulated + sizeof(accumulated), out);
+}
+
+// --- small helpers ---------------------------------------------------------
+
+static std::string HexEncode(const unsigned char* data, size_t length) {
+ static const char digits[] = "0123456789abcdef";
+ std::string out;
+ out.reserve(length * 2);
+
+ for (size_t i = 0; i < length; ++i) {
+ out += digits[(data[i] >> 4) & 0x0f];
+ out += digits[data[i] & 0x0f];
+ }
+
+ return out;
+}
+
+static int HexValue(char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+ return -1;
+}
+
+static bool HexDecode(const std::string& hex, std::vector& out) {
+ out.clear();
+
+ if (hex.empty() || (hex.size() % 2) != 0)
+ return false;
+
+ out.reserve(hex.size() / 2);
+
+ for (size_t i = 0; i < hex.size(); i += 2) {
+ int high = HexValue(hex[i]);
+ int low = HexValue(hex[i + 1]);
+
+ if (high < 0 || low < 0) {
+ out.clear();
+ return false;
+ }
+
+ out.push_back(static_cast((high << 4) | low));
+ }
+
+ return true;
+}
+
+// Compare two secrets without leaking how many characters matched.
+static bool ConstantTimeEquals(const std::string& left, const std::string& right) {
+ if (left.size() != right.size())
+ return false;
+
+ unsigned char difference = 0;
+ for (size_t i = 0; i < left.size(); ++i)
+ difference |= static_cast(left[i] ^ right[i]);
+
+ return difference == 0;
+}
+
+// Fill a buffer with random bytes: rand_s() on MSVC, /dev/urandom on Linux and
+// std::random_device only as a last resort.
+static bool RandomBytes(unsigned char* out, size_t count) {
+#if defined(_MSC_VER)
+ size_t written = 0;
+ while (written < count) {
+ unsigned int value = 0;
+ if (rand_s(&value) != 0)
+ break;
+
+ for (int byte = 0; byte < 4 && written < count; ++byte)
+ out[written++] = static_cast((value >> (byte * 8)) & 0xff);
+ }
+
+ if (written == count)
+ return true;
+#else
+ std::ifstream urandom("/dev/urandom", std::ios::binary);
+ if (urandom.is_open()) {
+ urandom.read(reinterpret_cast(out), static_cast(count));
+ if (urandom.gcount() == static_cast(count))
+ return true;
+ }
+#endif
+
+ static std::random_device fallback;
+ for (size_t i = 0; i < count; ++i)
+ out[i] = static_cast(fallback() & 0xff);
+
+ return true;
+}
+
+// base64 -> bytes, lenient about padding but strict about the alphabet.
+static bool Base64Decode(const std::string& input, std::string& out) {
+ auto value = [](unsigned char c) -> int {
+ if (c >= 'A' && c <= 'Z') return c - 'A';
+ if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+ if (c >= '0' && c <= '9') return c - '0' + 52;
+ if (c == '+') return 62;
+ if (c == '/') return 63;
+ return -1;
+ };
+
+ out.clear();
+
+ uint32_t buffer = 0;
+ int bits = 0;
+
+ for (unsigned char c : input) {
+ if (c == '=')
+ break;
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
+ continue;
+
+ int decoded = value(c);
+ if (decoded < 0)
+ return false;
+
+ buffer = (buffer << 6) | static_cast(decoded);
+ bits += 6;
+
+ if (bits >= 8) {
+ bits -= 8;
+ out += static_cast((buffer >> bits) & 0xff);
+ }
+ }
+
+ return true;
+}
+
+// `Basic dXNlcjpwYXNz` -> the name and password it carries.
+static bool ParseBasicCredentials(const std::string& headerValue, std::string& outName,
+ std::string& outPassword) {
+ std::string trimmed = HttpTrim(headerValue);
+ if (trimmed.size() < 6 || ToLower(trimmed.substr(0, 5)) != "basic")
+ return false;
+
+ std::string decoded;
+ if (!Base64Decode(HttpTrim(trimmed.substr(5)), decoded))
+ return false;
+
+ size_t separator = decoded.find(':');
+ if (separator == std::string::npos)
+ return false;
+
+ outName = decoded.substr(0, separator);
+ outPassword = decoded.substr(separator + 1);
+ return true;
+}
+
+static bool ParseBasicAuthorization(const std::string& headerBlock, std::string& outName,
+ std::string& outPassword) {
+ std::string headerValue;
+ if (!GetHeaderValue(headerBlock, "Authorization", headerValue))
+ return false;
+
+ return ParseBasicCredentials(headerValue, outName, outPassword);
+}
+
+// A realm ends up inside a quoted header value, so quotes and control
+// characters are dropped.
+static std::string SanitizeRealm(const std::string& realm) {
+ std::string cleaned;
+
+ for (unsigned char c : realm) {
+ if (c == '"' || c == '\\' || c < 0x20 || c == 0x7f)
+ continue;
+
+ cleaned += static_cast(c);
+ }
+
+ return cleaned.empty() ? std::string("Restricted") : cleaned;
+}
+
+// --- user store ------------------------------------------------------------
+
+struct AuthUser {
+ std::string name;
+ std::string salt; // hex encoded random bytes
+ std::string hash; // hex encoded PBKDF2-HMAC-SHA256 output
+ int iterations = 0;
+};
+
+static const int kAuthMinIterations = 1000;
+static const int kAuthMaxIterations = 1000000;
+static const int kAuthSaltBytes = 16;
+
+static std::vector g_authUsers;
+static std::string g_authFile = "users.json";
+static std::string g_authRealm = "Restricted";
+static int g_authIterations = 100000;
+
+// Exact request paths and path prefixes that require credentials.
+static std::vector g_authExactPaths;
+static std::vector g_authPrefixPaths;
+
+// Successful logins are remembered as `username -> sha256(password)`, so that
+// the deliberately slow PBKDF2 work is not repeated for every single request of
+// a browsing session. Cleared whenever the user list changes.
+static std::unordered_map g_authVerified;
+
+static bool g_authLoaded = false;
+static bool g_authFileBroken = false; // file exists but could not be parsed
+static bool g_authWarnedMissingUsers = false;
+
+static const AuthUser* FindAuthUser(const std::string& name) {
+ for (const auto& user : g_authUsers) {
+ if (ConstantTimeEquals(user.name, name))
+ return &user;
+ }
+
+ return nullptr;
+}
+
+static std::string JsonEscape(const std::string& value) {
+ std::string out;
+ out.reserve(value.size() + 8);
+
+ for (unsigned char c : value) {
+ switch (c) {
+ case '"': out += "\\\""; break;
+ case '\\': out += "\\\\"; break;
+ case '\b': out += "\\b"; break;
+ case '\f': out += "\\f"; break;
+ case '\n': out += "\\n"; break;
+ case '\r': out += "\\r"; break;
+ case '\t': out += "\\t"; break;
+ default:
+ if (c < 0x20) {
+ char escape[8];
+ std::snprintf(escape, sizeof(escape), "\\u%04x", c);
+ out += escape;
+ }
+ else {
+ out += static_cast(c);
+ }
+ }
+ }
+
+ return out;
+}
+
+static std::string SerializeAuthUsers() {
+ std::ostringstream out;
+ out << "{\n \"algorithm\": \"pbkdf2-sha256\",\n \"users\": [";
+
+ for (size_t i = 0; i < g_authUsers.size(); ++i) {
+ const AuthUser& user = g_authUsers[i];
+
+ out << (i == 0 ? "\n" : ",\n")
+ << " {\n"
+ << " \"username\": \"" << JsonEscape(user.name) << "\",\n"
+ << " \"salt\": \"" << user.salt << "\",\n"
+ << " \"hash\": \"" << user.hash << "\",\n"
+ << " \"iterations\": " << user.iterations << "\n"
+ << " }";
+ }
+
+ if (!g_authUsers.empty())
+ out << "\n ";
+
+ out << "]\n}\n";
+ return out.str();
+}
+
+// Write the user file through a temporary file, so an interrupted write cannot
+// leave a half-written users.json behind.
+static bool SaveAuthUsers() {
+ const std::string temporary = g_authFile + ".tmp";
+
+ if (!WriteFileBinary(temporary, SerializeAuthUsers()))
+ return false;
+
+#ifndef DLANG_HTTP_WINDOWS
+ // The file holds password hashes: keep it readable for the owner only.
+ std::error_code permissionError;
+ std::filesystem::permissions(
+ temporary,
+ std::filesystem::perms::owner_read | std::filesystem::perms::owner_write,
+ std::filesystem::perm_options::replace, permissionError);
+#endif
+
+ std::error_code ec;
+ std::filesystem::remove(g_authFile, ec); // rename() won't overwrite on Windows
+
+ ec.clear();
+ std::filesystem::rename(temporary, g_authFile, ec);
+
+ if (ec) {
+ std::cerr << "auth: could not write '" << g_authFile << "': " << ec.message() << "\n";
+ return false;
+ }
+
+ return true;
+}
+
+static void LoadAuthUsers(DlangVM* vm) {
+ g_authUsers.clear();
+ g_authFileBroken = false;
+
+ std::error_code ec;
+ if (!std::filesystem::exists(g_authFile, ec) || ec)
+ return; // no file yet: simply no accounts
+
+ std::string contents;
+ if (!ReadFileBinary(g_authFile, contents)) {
+ std::cerr << "auth: could not read '" << g_authFile << "'\n";
+ g_authFileBroken = true;
+ return;
+ }
+
+ if (contents.compare(0, 3, "\xEF\xBB\xBF") == 0) // tolerate a UTF-8 BOM
+ contents.erase(0, 3);
+
+ if (HttpTrim(contents).empty())
+ return;
+
+ StackValue decoded;
+ if (!DecodeJsonValue(vm, contents, decoded) || decoded.type != StackValueType::Table) {
+ std::cerr << "auth: '" << g_authFile << "' is not valid JSON\n";
+ g_authFileBroken = true;
+ return;
+ }
+
+ StackValue usersField;
+ if (!GetTableField(decoded, "users", usersField) || usersField.type != StackValueType::List) {
+ std::cerr << "auth: '" << g_authFile << "' has no \"users\" list\n";
+ g_authFileBroken = true;
+ return;
+ }
+
+ auto list = std::get>(usersField.data);
+
+ for (const auto& entry : list->values) {
+ if (entry.type != StackValueType::Table)
+ continue;
+
+ StackValue nameValue, saltValue, hashValue, iterationsValue;
+ GetTableField(entry, "username", nameValue);
+ GetTableField(entry, "salt", saltValue);
+ GetTableField(entry, "hash", hashValue);
+ GetTableField(entry, "iterations", iterationsValue);
+
+ if (nameValue.type != StackValueType::String ||
+ saltValue.type != StackValueType::String ||
+ hashValue.type != StackValueType::String)
+ continue;
+
+ std::vector salt;
+ if (!HexDecode(std::get(saltValue.data), salt) || salt.empty())
+ continue;
+
+ AuthUser user;
+ user.name = std::get(nameValue.data);
+ user.salt = std::get(saltValue.data);
+ user.hash = std::get(hashValue.data);
+ user.iterations = g_authIterations;
+
+ if (iterationsValue.type == StackValueType::Int) {
+ int iterations = static_cast(std::get(iterationsValue.data));
+
+ if (iterations >= kAuthMinIterations && iterations <= kAuthMaxIterations)
+ user.iterations = iterations;
+ else
+ std::cerr << "auth: ignoring out-of-range iteration count for '"
+ << user.name << "'\n";
+ }
+
+ if (user.name.empty() || user.hash.empty())
+ continue;
+
+ g_authUsers.push_back(user);
+ }
+}
+
+static void EnsureAuthLoaded(DlangVM* vm) {
+ if (g_authLoaded)
+ return;
+
+ g_authLoaded = true;
+ LoadAuthUsers(vm);
+}
+
+static bool IsValidAuthUserName(const std::string& name) {
+ if (name.empty())
+ return false;
+
+ // A Basic credential is "name:password", so the name must not contain a
+ // colon, whitespace or control characters.
+ for (unsigned char c : name) {
+ if (c <= 0x20 || c == 0x7f || c == ':')
+ return false;
+ }
+
+ return true;
+}
+
+static bool SetAuthPassword(DlangVM* vm, const std::string& name, const std::string& password) {
+ if (!IsValidAuthUserName(name)) {
+ std::cerr << "auth.addUser: invalid user name (no ':', whitespace or control"
+ " characters, and it cannot be empty)\n";
+ return false;
+ }
+
+ if (password.empty()) {
+ std::cerr << "auth.addUser: the password cannot be empty\n";
+ return false;
+ }
+
+ EnsureAuthLoaded(vm);
+
+ if (g_authFileBroken) {
+ std::cerr << "auth.addUser: refusing to overwrite the unreadable '"
+ << g_authFile << "'\n";
+ return false;
+ }
+
+ unsigned char salt[kAuthSaltBytes];
+ if (!RandomBytes(salt, sizeof(salt))) {
+ std::cerr << "auth.addUser: no random source available for the salt\n";
+ return false;
+ }
+
+ AuthUser user;
+ user.name = name;
+ user.salt = HexEncode(salt, sizeof(salt));
+ user.iterations = g_authIterations;
+
+ std::vector saltBytes(salt, salt + sizeof(salt));
+ unsigned char derived[32];
+ Pbkdf2Sha256(password, saltBytes, user.iterations, derived);
+ user.hash = HexEncode(derived, sizeof(derived));
+
+ std::vector previous = g_authUsers;
+
+ bool replaced = false;
+ for (auto& existing : g_authUsers) {
+ if (existing.name == name) {
+ existing = user;
+ replaced = true;
+ break;
+ }
+ }
+
+ if (!replaced)
+ g_authUsers.push_back(user);
+
+ g_authVerified.clear();
+
+ if (!SaveAuthUsers()) {
+ g_authUsers = previous; // keep memory and disk in sync
+ return false;
+ }
+
+ return true;
+}
+
+static bool RemoveAuthUser(DlangVM* vm, const std::string& name) {
+ EnsureAuthLoaded(vm);
+
+ std::vector previous = g_authUsers;
+
+ for (auto it = g_authUsers.begin(); it != g_authUsers.end(); ++it) {
+ if (it->name != name)
+ continue;
+
+ g_authUsers.erase(it);
+ g_authVerified.clear();
+
+ if (!SaveAuthUsers()) {
+ g_authUsers = previous;
+ return false;
+ }
+
+ return true;
+ }
+
+ return false;
+}
+
+static bool VerifyAuthPassword(DlangVM* vm, const std::string& name,
+ const std::string& password) {
+ EnsureAuthLoaded(vm);
+
+ const AuthUser* user = FindAuthUser(name);
+ if (user == nullptr)
+ return false;
+
+ // Cheap re-check for requests that already authenticated once.
+ unsigned char digest[32];
+ Sha256String(password, digest);
+ std::string fingerprint = HexEncode(digest, sizeof(digest));
+
+ auto cached = g_authVerified.find(user->name);
+ if (cached != g_authVerified.end() && ConstantTimeEquals(cached->second, fingerprint))
+ return true;
+
+ std::vector salt;
+ if (!HexDecode(user->salt, salt))
+ return false;
+
+ unsigned char derived[32];
+ Pbkdf2Sha256(password, salt, user->iterations, derived);
+
+ if (!ConstantTimeEquals(HexEncode(derived, sizeof(derived)), user->hash))
+ return false;
+
+ g_authVerified[user->name] = fingerprint;
+ return true;
+}
+
+// --- which paths are protected --------------------------------------------
+
+static std::string NormalizeAuthPath(const std::string& path) {
+ std::string normalized = HttpTrim(path);
+ if (normalized.empty())
+ return "/";
+
+ if (normalized.front() != '/')
+ normalized.insert(normalized.begin(), '/');
+
+ while (normalized.size() > 1 && normalized.back() == '/')
+ normalized.pop_back();
+
+ return normalized;
+}
+
+static bool AddAuthPath(std::vector& list, const std::string& path) {
+ for (const auto& entry : list) {
+ if (entry == path)
+ return true;
+ }
+
+ list.push_back(path);
+ return true;
+}
+
+static bool RemoveAuthPath(std::vector& list, const std::string& path) {
+ for (auto it = list.begin(); it != list.end(); ++it) {
+ if (*it == path) {
+ list.erase(it);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static bool AuthProtectsPath(const std::string& path) {
+ for (const auto& entry : g_authExactPaths) {
+ if (entry == path)
+ return true;
+ }
+
+ for (const auto& entry : g_authPrefixPaths) {
+ if (entry == "/")
+ return true;
+ if (path == entry)
+ return true;
+ if (path.size() > entry.size() &&
+ path.compare(0, entry.size(), entry) == 0 &&
+ path[entry.size()] == '/')
+ return true;
+ }
+
+ return false;
+}
+
+// 401 + WWW-Authenticate: the header that makes a browser open its own
+// username/password dialog. The body still honours server.error(401, ...).
+static void SendUnauthorized(DlangVM* vm, SocketHandle clientSocket,
+ const std::string& method, const std::string& path) {
+ ErrorResponse err = RenderError(vm, 401, method, path);
+
+ std::string headers = "WWW-Authenticate: Basic realm=\"" +
+ SanitizeRealm(g_authRealm) + "\", charset=\"UTF-8\"\r\n";
+
+ SendResponse(clientSocket, 401, err.contentType, err.body, headers);
+}
+
+// --- auth.* natives --------------------------------------------------------
+
+void NativeAuthFile(DlangVM* vm) {
+ // auth.file(path) -> the users file in use (default "users.json").
+ StackValue pathValue = dlang_pop(vm);
+
+ if (pathValue.type == StackValueType::String) {
+ std::string path = HttpTrim(std::get(pathValue.data));
+
+ if (!path.empty() && path != g_authFile) {
+ g_authFile = path;
+ g_authLoaded = false;
+ g_authFileBroken = false;
+ g_authWarnedMissingUsers = false;
+ }
+ }
+
+ dlang_pushstring(vm, g_authFile);
+}
+
+void NativeAuthRealm(DlangVM* vm) {
+ // auth.realm(name) -> the realm shown in the browser's login popup.
+ StackValue nameValue = dlang_pop(vm);
+
+ if (nameValue.type == StackValueType::String)
+ g_authRealm = SanitizeRealm(std::get(nameValue.data));
+
+ dlang_pushstring(vm, g_authRealm);
+}
+
+void NativeAuthIterations(DlangVM* vm) {
+ // auth.iterations(count) -> PBKDF2 rounds used for new/updated passwords.
+ StackValue value = dlang_pop(vm);
+
+ if (value.type == StackValueType::Int) {
+ double rounds = std::get(value.data);
+
+ if (rounds >= kAuthMinIterations && rounds <= kAuthMaxIterations)
+ g_authIterations = static_cast(rounds);
+ else
+ std::cerr << "auth.iterations: expected " << kAuthMinIterations << ".."
+ << kAuthMaxIterations << ", keeping " << g_authIterations << "\n";
+ }
+
+ dlang_pushnumber(vm, g_authIterations);
+}
+
+void NativeAuthAddUser(DlangVM* vm) {
+ // auth.addUser(name, password) -> true when the account was stored.
+ StackValue passwordValue = dlang_pop(vm);
+ StackValue nameValue = dlang_pop(vm);
+
+ if (nameValue.type != StackValueType::String || passwordValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, SetAuthPassword(vm, std::get(nameValue.data),
+ std::get(passwordValue.data)));
+}
+
+void NativeAuthRemoveUser(DlangVM* vm) {
+ // auth.removeUser(name) -> true when the account was removed.
+ StackValue nameValue = dlang_pop(vm);
+
+ if (nameValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, RemoveAuthUser(vm, std::get(nameValue.data)));
+}
+
+void NativeAuthHasUser(DlangVM* vm) {
+ // auth.hasUser(name) -> true when the account exists.
+ StackValue nameValue = dlang_pop(vm);
+
+ if (nameValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ EnsureAuthLoaded(vm);
+ dlang_pushboolean(vm, FindAuthUser(std::get(nameValue.data)) != nullptr);
+}
+
+void NativeAuthUsers(DlangVM* vm) {
+ // auth.users() -> list of the known user names.
+ EnsureAuthLoaded(vm);
+
+ auto list = std::make_shared();
+ for (const auto& user : g_authUsers)
+ list->Push(StackValue(StackValueType::String, user.name));
+
+ dlang_push(vm, StackValue(StackValueType::List, list));
+}
+
+void NativeAuthVerify(DlangVM* vm) {
+ // auth.verify(name, password) -> true when the password matches.
+ StackValue passwordValue = dlang_pop(vm);
+ StackValue nameValue = dlang_pop(vm);
+
+ if (nameValue.type != StackValueType::String || passwordValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, VerifyAuthPassword(vm, std::get(nameValue.data),
+ std::get(passwordValue.data)));
+}
+
+void NativeAuthLogin(DlangVM* vm) {
+ // auth.login(req) -> the authenticated name, or "" when there are no valid
+ // credentials in the request headers.
+ StackValue request = dlang_pop(vm);
+
+ std::string headerValue;
+ StackValue headers;
+ if (GetTableField(request, "headers", headers) && headers.type == StackValueType::Table) {
+ StackValue authorization;
+ if (GetTableField(headers, "authorization", authorization) &&
+ authorization.type == StackValueType::String)
+ headerValue = std::get(authorization.data);
+ }
+
+ std::string name, password;
+ if (!headerValue.empty() &&
+ ParseBasicCredentials(headerValue, name, password) &&
+ VerifyAuthPassword(vm, name, password)) {
+ dlang_pushstring(vm, name);
+ return;
+ }
+
+ dlang_pushstring(vm, "");
+}
+
+void NativeAuthReload(DlangVM* vm) {
+ // auth.reload() -> true when the users file was read successfully.
+ g_authLoaded = true;
+ g_authVerified.clear();
+ LoadAuthUsers(vm);
+ dlang_pushboolean(vm, !g_authFileBroken);
+}
+
+void NativeAuthProtect(DlangVM* vm) {
+ // auth.protect(path) -> require credentials for exactly this path.
+ StackValue pathValue = dlang_pop(vm);
+
+ if (pathValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, AddAuthPath(g_authExactPaths,
+ NormalizeAuthPath(std::get(pathValue.data))));
+}
+
+void NativeAuthProtectPrefix(DlangVM* vm) {
+ // auth.protectPrefix(path) -> require credentials for this path and below.
+ StackValue pathValue = dlang_pop(vm);
+
+ if (pathValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, AddAuthPath(g_authPrefixPaths,
+ NormalizeAuthPath(std::get(pathValue.data))));
+}
+
+void NativeAuthUnprotect(DlangVM* vm) {
+ // auth.unprotect(path) -> drop an exact-path protection again.
+ StackValue pathValue = dlang_pop(vm);
+
+ if (pathValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, RemoveAuthPath(g_authExactPaths,
+ NormalizeAuthPath(std::get(pathValue.data))));
+}
+
+void NativeAuthUnprotectPrefix(DlangVM* vm) {
+ // auth.unprotectPrefix(path) -> drop a prefix protection again.
+ StackValue pathValue = dlang_pop(vm);
+
+ if (pathValue.type != StackValueType::String) {
+ dlang_pushboolean(vm, false);
+ return;
+ }
+
+ dlang_pushboolean(vm, RemoveAuthPath(g_authPrefixPaths,
+ NormalizeAuthPath(std::get(pathValue.data))));
+}
+
// Read a full HTTP request: the header block plus the body described by
// Content-Length. `headerEnd` is set to the offset of the blank line that
// terminates the headers. Returns false when no complete header block arrived.
@@ -1551,6 +2634,32 @@ static void HandleRequest(DlangVM* vm, SocketHandle clientSocket,
std::string path = (qPos != std::string::npos) ? fullPath.substr(0, qPos) : fullPath;
std::string query = (qPos != std::string::npos) ? fullPath.substr(qPos + 1) : std::string();
+ // HTTP Basic auth gate: only the paths registered through auth.protect(...)
+ // or auth.protectPrefix(...) need credentials, everything else stays open.
+ // Without valid credentials the request is refused here, before a route
+ // handler or a static file is even looked up.
+ std::string authenticatedUser;
+
+ if (AuthProtectsPath(path)) {
+ EnsureAuthLoaded(vm);
+
+ if (g_authUsers.empty() && !g_authWarnedMissingUsers) {
+ g_authWarnedMissingUsers = true;
+ std::cerr << "auth: no accounts in '" << g_authFile << "' yet - create one with "
+ << "auth.addUser(\"name\", \"password\") before listen().\n";
+ }
+
+ std::string basicName, basicPassword;
+ if (ParseBasicAuthorization(headerBlock, basicName, basicPassword) &&
+ VerifyAuthPassword(vm, basicName, basicPassword)) {
+ authenticatedUser = basicName;
+ }
+ else {
+ SendUnauthorized(vm, clientSocket, method, path);
+ return;
+ }
+ }
+
// An oversized upload is refused before anything else is parsed.
if (bodyTooLarge) {
ErrorResponse err = RenderError(vm, 413, method, path);
@@ -1586,9 +2695,13 @@ static void HandleRequest(DlangVM* vm, SocketHandle clientSocket,
if (GetHeaderValue(headerBlock, "Cookie", cookieHeader))
ParseCookies(cookieHeader, *cookiesTable);
+ auto headersTable = std::make_shared();
+ ParseHeaders(headerBlock, *headersTable);
+
StackValue paramsValue(StackValueType::Table, paramsTable);
StackValue cookiesValue(StackValueType::Table, cookiesTable);
StackValue filesValue(StackValueType::Table, filesTable);
+ StackValue headersValue(StackValueType::Table, headersTable);
const RouteHandler* matched = nullptr;
bool pathMatched = false;
@@ -1637,6 +2750,17 @@ static void HandleRequest(DlangVM* vm, SocketHandle clientSocket,
dlang_push(vm, filesValue);
dlang_setfield(vm, "files");
+ // All request headers, names lower-cased; repeated headers become a list.
+ dlang_push(vm, headersValue);
+ dlang_setfield(vm, "headers");
+
+ // Only set for a request that passed the auth gate (or that carried
+ // valid credentials for a path protected with auth.protect...).
+ if (!authenticatedUser.empty()) {
+ dlang_pushstring(vm, authenticatedUser.c_str());
+ dlang_setfield(vm, "user");
+ }
+
// arg 2: params, arg 3: cookies, arg 4: files
dlang_push(vm, paramsValue);
dlang_push(vm, cookiesValue);
@@ -1799,5 +2923,54 @@ extern "C" {
dlang_setfield(vm, "render");
dlang_setglobal(vm, "view");
+
+ // auth.* — HTTP Basic authentication: the user store (`users.json` with
+ // PBKDF2-hashed passwords), the login realm and the list of protected
+ // paths. See the auth module above.
+ dlang_newtable(vm);
+
+ dlang_pushcfunction(vm, NativeAuthFile);
+ dlang_setfield(vm, "file");
+
+ dlang_pushcfunction(vm, NativeAuthRealm);
+ dlang_setfield(vm, "realm");
+
+ dlang_pushcfunction(vm, NativeAuthIterations);
+ dlang_setfield(vm, "iterations");
+
+ dlang_pushcfunction(vm, NativeAuthAddUser);
+ dlang_setfield(vm, "addUser");
+
+ dlang_pushcfunction(vm, NativeAuthRemoveUser);
+ dlang_setfield(vm, "removeUser");
+
+ dlang_pushcfunction(vm, NativeAuthHasUser);
+ dlang_setfield(vm, "hasUser");
+
+ dlang_pushcfunction(vm, NativeAuthUsers);
+ dlang_setfield(vm, "users");
+
+ dlang_pushcfunction(vm, NativeAuthVerify);
+ dlang_setfield(vm, "verify");
+
+ dlang_pushcfunction(vm, NativeAuthLogin);
+ dlang_setfield(vm, "login");
+
+ dlang_pushcfunction(vm, NativeAuthReload);
+ dlang_setfield(vm, "reload");
+
+ dlang_pushcfunction(vm, NativeAuthProtect);
+ dlang_setfield(vm, "protect");
+
+ dlang_pushcfunction(vm, NativeAuthProtectPrefix);
+ dlang_setfield(vm, "protectPrefix");
+
+ dlang_pushcfunction(vm, NativeAuthUnprotect);
+ dlang_setfield(vm, "unprotect");
+
+ dlang_pushcfunction(vm, NativeAuthUnprotectPrefix);
+ dlang_setfield(vm, "unprotectPrefix");
+
+ dlang_setglobal(vm, "auth");
}
}
\ No newline at end of file