Added basic auth

This commit is contained in:
Damian committed 2026-10-15 12:27:27 +02:00
1 parent 7d5a4693f9
commit 9b2e1692d4
3 files changed
+1297 -12

No files matched your search

+5 -1
View File
@@ -11,4 +11,8 @@ compile_commands.json
CTestTestfile.cmake
_deps
CMakeUserPresets.json
out/
out/
# Auth user store (holds password hashes) and its temporary file
users.json
users.json.tmp
+116 -8
View File
@@ -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 "<h1>Welcome back, " + req.user + "</h1>";
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<br/>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<br/>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
+1176 -3
View File
File diff suppressed because it is too large. Load diff