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