refactor: replace Bearer token auth with HttpOnly cookies #84

Open
clawbot wants to merge 1 commits from feature/cookie-auth-refactor into main
11 changed files with 625 additions and 711 deletions
Showing only changes of commit cd9fd0c5c5 - Show all commits

454
README.md
View File

@@ -115,7 +115,7 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key — a nick is a display name.
Accounts are optional: you can create an anonymous session instantly, or
register with a password for multi-client access to a single session.
set a password via the PASS command for multi-client access to a single session.
### On the resemblance to JSON-RPC
@@ -149,63 +149,59 @@ not arbitrary choices — each one follows from the project's core thesis that
IRC's command model is correct and only the transport and session management
need to change.
### Identity & Sessions — Dual Authentication Model
### Identity & Sessions — Cookie-Based Authentication
The server supports two authentication paths: **anonymous sessions** for
instant access, and **optional account registration** for multi-client access.
The server uses **HTTP cookies** for all authentication. There is no separate
registration step — sessions start anonymous and can optionally set a password
for multi-client access.
#### Anonymous Sessions (No Account Required)
The simplest entry point. No registration, no passwords.
#### Session Creation
- **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
- Anonymous sessions are ephemeral — when the session expires or the user
QUITs, the nick is released and there is no way to reclaim it.
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
a cryptographically random token (64 hex characters) and returns the user
ID and nick in the JSON response body. No token appears in the JSON body.
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
Clients never need to handle the token directly — the browser/HTTP client
manages cookies automatically.
- Sessions start anonymous — no password required. When the session expires
or the user QUITs, the nick is released.
#### Registered Accounts (Optional)
#### Setting a Password (Optional, for Multi-Client Access)
For users who want multi-client access (multiple devices sharing one session):
For users who want to access the same session from multiple devices:
- **Registration**: client sends `POST /api/v1/register` with a nick and
password (minimum 8 characters) → server creates a session with the
password hashed via bcrypt, and returns the user ID, nick, and auth token.
- **Login**: client sends `POST /api/v1/login` with nick and password →
server verifies the password against the stored bcrypt hash and creates a
new client token for the existing session. This enables multi-client
access: logging in from a new device adds a client to the existing session
rather than creating a new one, so channel memberships and message queues
are shared. Note: login only works while the session still exists — if all
clients have logged out or the user has sent QUIT, the session is deleted
and the registration is lost.
- Registered accounts cannot be logged into via `POST /api/v1/session`
that endpoint is for anonymous sessions only.
- Anonymous sessions (created via `/session`) cannot be logged into via
`/login` because they have no password set.
- **Set password via IRC PASS command**: the authenticated client sends
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
The server hashes the password with bcrypt and stores it on the session.
Password must be at least 8 characters.
- **Login from another client**: `POST /api/v1/login` with nick and password →
server verifies the password, creates a new client for the existing session,
and sets an auth cookie. Channel memberships and message queues are shared.
Login only works while the session still exists — if all clients have logged
out or the user has sent QUIT, the session is deleted and the password is
lost.
#### Common Properties (Both Paths)
#### Common Properties
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
the stable identity.
- Server-assigned IDs — clients do not choose their own IDs.
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
in the token, no client-side decode. The server is the sole authority on
token validity.
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
encoded in the token, no client-side decode. The server is the sole authority
on cookie validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. Optional registration with password enables multi-client login
without adding friction for casual users: if you don't want an account,
don't create one. Note: in the current implementation, both anonymous and
registered sessions are deleted when the last client disconnects (QUIT or
logout); registration does not make a session survive all-client
removal. Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent
of account registration.
a bouncer. The PASS command enables multi-client login without adding friction
for casual users: if you don't need multi-client, just create a session and
go. Cookie-based auth eliminates token management from client code entirely —
browsers and HTTP cookie jars handle it automatically. Note: both anonymous
and password-protected sessions are deleted when the last client disconnects
(QUIT or logout). Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent of
password status.
### Nick Semantics
@@ -232,7 +228,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
- The server fans out all S2C messages to every active client queue for that
user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth token.
identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others.
@@ -244,11 +240,10 @@ User Session
```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing registered session, enabling true multi-client support
(multiple tokens sharing one nick/session with independent message queues).
Anonymous sessions created via `POST /api/v1/session` always create a new
user with a new nick. A future endpoint to "add a client to an existing
anonymous session" is planned but not yet implemented.
client to an existing session (one that has a password set via PASS command),
enabling true multi-client support (multiple cookies sharing one nick/session
with independent message queues). Sessions without a password cannot be
logged into.
**Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side
@@ -355,19 +350,22 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing.
### Why Opaque Tokens Instead of JWTs
### Why Opaque Cookies Instead of JWTs
JWTs encode claims that clients can decode and potentially rely on. This
creates a coupling between token format and client behavior. If the server
needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly.
Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the raw token; server hashes and looks it up
- Revocation is a database delete
Opaque auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
sets raw hex as an HttpOnly cookie
- On each request, server hashes the cookie value and looks it up
- Revocation is a database delete (cookie becomes invalid immediately)
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Token format can change without breaking clients
- Cookie format can change without breaking clients
- Clients never handle tokens directly — browsers and HTTP cookie jars
manage everything automatically
---
@@ -391,17 +389,18 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Anonymous Session
#### Session Creation
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │
│ (Server fans out JOIN event to all #general members)
│ (Cookie sent automatically on all subsequent requests)
│ │
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │
@@ -418,31 +417,37 @@ The entire read/write loop for a client is two endpoints. Everything else
│ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick)
│ deletes session, releases nick, clears cookie)
│ │
└────────────────────────────────────────────────────────────┘
```
#### Registered Account
#### Multi-Client via Password
```
┌─ Client ──────────────────────────────────────────────────┐
┌─ Client A ────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/register
{"nick":"alice", "password":"s3cret!!"}
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
(Session created with bcrypt-hashed password)
│ 1. POST /api/v1/session {"nick":"alice"}
→ Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ...
│ → {"id":1, "nick":"alice"}
│ 2. POST /api/v1/messages │
│ {"command":"PASS","body":["s3cret!!"]} │
│ → {"status":"ok"} │
│ (Password set via IRC PASS command) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
│ (From another device, while session is still active) │
└────────────────────────────────────────────────────────────┘
┌─ Client B (another device, while session is still active) ┐
│ │
2. POST /api/v1/login │
3. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."}
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ...
│ → {"id":1, "nick":"alice"} │
│ (New client added to existing session — channels │
│ and message queues are preserved. If all clients
│ have logged out, session no longer exists.) │
│ and message queues are preserved.)
│ │
└────────────────────────────────────────────────────────────┘
```
@@ -729,6 +734,35 @@ Change the user's nickname.
**IRC reference:** RFC 1459 §4.1.2
#### PASS — Set Session Password
Set a password on the current session, enabling multi-client login via
`POST /api/v1/login`. The password is hashed with bcrypt and stored
server-side.
**C2S:**
```json
{"command": "PASS", "body": ["mypassword"]}
```
**Behavior:**
- `body[0]` is the password. Must be at least 8 characters.
- On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick
and password to create additional clients on the same session.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
session password management.
#### TOPIC — Set Channel Topic
Set or change a channel's topic.
@@ -795,7 +829,7 @@ Destroy the session and disconnect from the server.
quitting user. The quitting user does **not** receive their own QUIT.
- The user is removed from all channels.
- Empty channels are deleted (ephemeral).
- The user's session is destroyed — the auth token is invalidated, the nick
- The user's session is destroyed — the auth cookie is invalidated, the nick
is released.
- Subsequent requests with the old token return HTTP 401.
@@ -1086,8 +1120,8 @@ to compute stamps. Higher bit counts take exponentially longer to compute.
## API Reference
All endpoints accept and return `application/json`. Authenticated endpoints
require `Authorization: Bearer <token>` header. The token is obtained from
`POST /api/v1/session`.
require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session` and `POST /api/v1/login`.
All API responses include appropriate HTTP status codes. Error responses have
the format:
@@ -1116,11 +1150,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created`
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
The JSON body does **not** include the token.
```
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
```
```json
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
"nick": "alice"
}
```
@@ -1128,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick (always matches request on success) |
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
**Cookie properties:**
| Property | Value |
|------------|-------|
| `Name` | `neoirc_auth` |
| `HttpOnly` | `true` (not accessible from JavaScript) |
| `SameSite` | `Strict` (prevents CSRF) |
| `Secure` | `true` when behind TLS |
| `Path` | `/` |
**Errors:**
@@ -1141,66 +1191,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
# Use -c to save cookies, -b to send them
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/register — Register Account
Create a new user session with a password. The password is hashed
with bcrypt and stored server-side. The password enables login from
additional clients via `POST /api/v1/login` while the session
remains active.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `password` | string | Yes | Minimum 8 characters |
**Response:** `201 Created`
```json
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick |
| `token` | string | 64-character hex auth token |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid nick format` | Nick doesn't match allowed format |
| 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
```
### POST /api/v1/login — Login to Account
Authenticate with a previously registered nick and password. Creates a new
client token for the existing session, preserving channel memberships and
message queues. This is how multi-client access works for registered accounts:
each login adds a new client to the session.
Authenticate with a nick and password (set via the PASS IRC command). Creates a
new client for the existing session, preserving channel memberships and message
queues. This is how multi-client access works: each login adds a new client to
the session with its own auth cookie and message delivery queue.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
@@ -1213,37 +1215,37 @@ new client's queue, so the client can immediately restore its UI state.
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match a registered account |
| `password` | string | Yes | Must match the account's password |
| `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the session's password |
**Response:** `200 OK`
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
```json
{
"id": 1,
"nick": "alice",
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
"nick": "alice"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID (same as when registered) |
| `id` | integer | Session ID |
| `nick` | string | Current nick |
| `token` | string | New 64-character hex auth token for this client |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
-d '{"nick":"alice","password":"mypassword"}'
```
### GET /api/v1/state — Get Session State
@@ -1287,13 +1289,13 @@ Each channel object:
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1353,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):**
```bash
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
```
**curl example (long-poll, 15s):**
```bash
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
```
### POST /api/v1/messages — Send Command
@@ -1387,6 +1387,7 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
@@ -1401,14 +1402,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500).
auth cookies (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth token |
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):**
@@ -1499,11 +1500,11 @@ events). Event messages are delivered via the live queue only.
```bash
# Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
# Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/channels — List Channels
@@ -1534,18 +1535,22 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
Destroy the current client's session cookie and server-side client record.
If no other clients remain on the session, the user is fully cleaned up:
parted from all channels (with QUIT broadcast to members), session deleted,
nick released. The auth cookie is cleared in the response.
**Request:** No body. Requires auth.
**Request:** No body. Requires auth cookie.
**Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json
{"status": "ok"}
```
@@ -1554,12 +1559,11 @@ broadcast to members), session deleted, nick released.
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
| 401 | `unauthorized` | Missing or invalid auth cookie |
**curl example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
```
### GET /api/v1/users/me — Current User Info
@@ -1583,7 +1587,7 @@ Return the current user's session state. This is an alias for
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/server — Server Info
@@ -1828,20 +1832,21 @@ authenticity.
### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are hashed (SHA-256) before storage and validated on every request.
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
entropy). Tokens are hashed (SHA-256) before storage and validated on every
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
(CSRF protection), and Secure when behind TLS.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The token is the sole credential.
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
(minimum 8 characters). The password is hashed with bcrypt at the default
cost factor and stored alongside the session. `POST /api/v1/login`
authenticates against the stored hash and issues a new client token.
password, instant access. The auth cookie is the sole credential.
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
password on the session. `POST /api/v1/login` authenticates against the
stored hash and issues a new client cookie.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Anonymous sessions have
an empty `password_hash` and cannot be logged into via `/login`.
- **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry.
handles salting and key stretching automatically. Sessions without a
password cannot be logged into via `/login`.
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
production. If a cookie is compromised, the attacker has full access to the
session until QUIT or expiry.
### Message Integrity
@@ -1878,8 +1883,10 @@ authenticity.
- **HTTPS is strongly recommended** for production deployments. The server
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed.
- **CORS**: The server allows all origins with credentials
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
This enables cookie-based auth from cross-origin clients. Restrict origins
in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
@@ -2069,7 +2076,7 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
@@ -2230,68 +2237,59 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1a. Create an anonymous session (no account)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
# 1a. Create a session (cookie saved automatically with -c)
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser"}' | jq -r .token)
-d '{"nick":"testuser"}'
# 1b. Or register an account (multi-client support)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
# 1b. Optionally set a password for multi-client access
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
-d '{"command":"PASS","body":["mypassword"]}'
# 1c. Or login to an existing account
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
# 1c. Login from another device (saves new cookie)
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
-d '{"nick":"testuser","password":"mypassword"}'
# 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}'
# 3. Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
# 5. Long-poll (blocks up to 15s waiting for messages)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
# 6. Send a DM
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
# 7. Change nick
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"NICK","body":["newnick"]}'
# 8. Set channel topic
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
# 9. Leave a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
# 10. Disconnect
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}'
```
@@ -2301,27 +2299,25 @@ curl -s -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern:
```python
# Python example
import requests, json
# Python example — using requests.Session for automatic cookie handling
import requests, json, time
BASE = "http://localhost:8080/api/v1"
token = None
session = requests.Session() # Manages cookies automatically
last_id = 0
# Create session
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Create session (cookie set automatically via Set-Cookie header)
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}")
# Join channel
requests.post(f"{BASE}/messages", headers=headers,
json={"command": "JOIN", "to": "#general"})
session.post(f"{BASE}/messages",
json={"command": "JOIN", "to": "#general"})
# Poll loop
while True:
try:
resp = requests.get(f"{BASE}/messages",
headers=headers,
resp = session.get(f"{BASE}/messages",
params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout
data = resp.json()
@@ -2338,14 +2334,14 @@ while True:
```
```javascript
// JavaScript/browser example
async function pollLoop(token) {
// JavaScript/browser example — cookies sent automatically
async function pollLoop() {
let lastId = 0;
while (true) {
try {
const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`,
{headers: {'Authorization': `Bearer ${token}`}}
{credentials: 'same-origin'} // Include cookies
);
if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json();
@@ -2377,8 +2373,8 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
re-login (registered account).
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (if a password was set).
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
@@ -2398,10 +2394,11 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
For anonymous sessions, create a new session. For registered accounts,
log in again via `POST /api/v1/login` to get a fresh token on the same
session. If it fails with a network error, retry with backoff.
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
invalid. For sessions without a password, create a new session. For
sessions with a password set (via PASS command), log in again via
`POST /api/v1/login` to get a fresh cookie on the same session. If it
fails with a network error, retry with backoff.
---
@@ -2560,8 +2557,10 @@ creating one session pays once and keeps their session.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session
(share nick across devices)
- [x] **Multi-client sessions** — set a password via PASS command, then
login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
### Future (1.0+)
@@ -2674,13 +2673,12 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(multiple devices sharing one session), optional account registration
with password is available — but never required. Identity
verification at the message layer uses cryptographic signing,
independent of account status.
(multiple devices sharing one session), the PASS command sets a password
on the session — but it's never required. Identity verification at the
message layer uses cryptographic signing, independent of password status.
3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
@@ -28,16 +29,19 @@ var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates a new API client.
// NewClient creates a new API client with a cookie jar
// for automatic auth cookie management.
func NewClient(baseURL string) *Client {
return &Client{ //nolint:exhaustruct // Token set after CreateSession
jar, _ := cookiejar.New(nil)
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
Jar: jar,
},
}
}
@@ -79,8 +83,6 @@ func (client *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err)
}
client.Token = resp.Token
return &resp, nil
}
@@ -121,6 +123,7 @@ func (client *Client) PollMessages(
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
Jar: client.HTTPClient.Jar,
}
params := url.Values{}
@@ -145,10 +148,6 @@ func (client *Client) PollMessages(
return nil, fmt.Errorf("new request: %w", err)
}
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := pollClient.Do(request)
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
@@ -304,12 +303,6 @@ func (client *Client) do(
"Content-Type", "application/json",
)
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)

View File

@@ -10,9 +10,8 @@ type SessionRequest struct {
// SessionResponse is the response from session creation.
type SessionResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
ID int64 `json:"id"`
Nick string `json:"nick"`
}
// StateResponse is the response from GET /api/v1/state.

View File

@@ -16,80 +16,28 @@ var errNoPassword = errors.New(
"account has no password set",
)
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
// SetPassword sets a bcrypt-hashed password on a session,
// enabling multi-client login via POST /api/v1/login.
func (database *Database) SetPassword(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
sessionID int64,
password string,
) error {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
return fmt.Errorf("hash password: %w", err)
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
_, err = database.conn.ExecContext(ctx,
"UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
if err != nil {
return 0, 0, "", err
return fmt.Errorf("set password: %w", err)
}
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
return nil
}
// LoginUser verifies a nick/password and creates a new

View File

@@ -6,63 +6,65 @@ import (
_ "modernc.org/sqlite"
)
func TestRegisterUser(t *testing.T) {
func TestSetPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
sessionID, _, _, err :=
database.CreateSession(ctx, "passuser")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
err = database.SetPassword(
ctx, sessionID, "password123",
)
if err != nil {
t.Fatal(err)
}
// Verify we can now log in with the password.
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "passuser", "password123")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || loginToken == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
func TestSetPasswordThenWrongLogin(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
sessionID, _, _, err :=
database.CreateSession(ctx, "wrongpw")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
err = database.SetPassword(
ctx, sessionID, "correctpass",
)
if err != nil {
t.Fatal(err)
}
_ = dupSID
_ = dupCID
_ = dupToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUser(t *testing.T) {
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
sessionID, _, _, err :=
database.CreateSession(ctx, "loginuser")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
err = database.SetPassword(
ctx, sessionID, "mypassword",
)
if err != nil {
t.Fatal(err)
}
sessionID, clientID, token, err :=
loginSID, loginCID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
if loginSID == 0 || loginCID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
@@ -103,33 +108,6 @@ func TestLoginUser(t *testing.T) {
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()

View File

@@ -36,6 +36,7 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
authCookieName = "neoirc_auth"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -46,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authSession extracts the session from the client token.
// authSession extracts the session from the auth cookie.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
cookie, err := request.Cookie(authCookieName)
if err != nil || cookie.Value == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), token,
request.Context(), cookie.Value,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -71,6 +67,46 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil
}
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
@@ -226,10 +262,11 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
"id": sessionID,
"nick": payload.Nick,
}, http.StatusCreated)
}
@@ -875,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
@@ -2005,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID,
)
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
@@ -2807,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
)
}
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)

View File

@@ -33,15 +33,16 @@ import (
)
const (
commandKey = "command"
bodyKey = "body"
toKey = "to"
statusKey = "status"
privmsgCmd = "PRIVMSG"
joinCmd = "JOIN"
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
commandKey = "command"
bodyKey = "body"
toKey = "to"
statusKey = "status"
privmsgCmd = "PRIVMSG"
joinCmd = "JOIN"
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
authCookieName = "neoirc_auth"
)
// testServer wraps a test HTTP server with helpers.
@@ -261,7 +262,7 @@ func doRequest(
func doRequestAuth(
t *testing.T,
method, url, token string,
method, url, cookie string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
@@ -279,10 +280,11 @@ func doRequestAuth(
)
}
if token != "" {
request.Header.Set(
"Authorization", "Bearer "+token,
)
if cookie != "" {
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
Name: authCookieName,
Value: cookie,
})
}
resp, err := http.DefaultClient.Do(request)
@@ -325,17 +327,19 @@ func (tserver *testServer) createSession(
)
}
var result struct {
ID int64 `json:"id"`
Token string `json:"token"`
// Drain the body.
_, _ = io.ReadAll(resp.Body)
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
}
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
tserver.t.Fatal("no auth cookie in response")
return result.Token
return ""
}
func (tserver *testServer) sendCommand(
@@ -492,10 +496,10 @@ func findNumeric(
func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
cookie := tserver.createSession("alice")
if token == "" {
t.Fatal("expected token")
if cookie == "" {
t.Fatal("expected auth cookie")
}
}
@@ -617,7 +621,7 @@ func TestCreateSessionMalformed(t *testing.T) {
}
}
func TestAuthNoHeader(t *testing.T) {
func TestAuthNoCookie(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
@@ -626,11 +630,11 @@ func TestAuthNoHeader(t *testing.T) {
}
}
func TestAuthBadToken(t *testing.T) {
func TestAuthBadCookie(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState(
"invalid-token-12345",
"invalid-cookie-12345",
)
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
@@ -1827,90 +1831,6 @@ func assertFieldGTE(
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
@@ -1945,36 +1865,102 @@ func postJSONExpectStatus(
}
}
func TestRegisterShortPassword(t *testing.T) {
func TestPassCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("passuser")
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
// Drain initial messages.
_, _ = tserver.pollMessages(token, 0)
// Set password via PASS command.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"s3cure_pass"},
},
http.StatusBadRequest,
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok, got %v", result[statusKey],
)
}
}
func TestRegisterInvalidNick(t *testing.T) {
func TestPassCommandShortPassword(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("shortpw")
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try short password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"short"},
},
http.StatusBadRequest,
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPassCommandEmpty(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptypw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try empty password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{commandKey: "PASS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Register first.
regBody, err := json.Marshal(map[string]string{
// Create session and set password via PASS command.
token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"password123"},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
@@ -1982,26 +1968,6 @@ func TestLoginValid(t *testing.T) {
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
@@ -2011,31 +1977,33 @@ func TestLoginValid(t *testing.T) {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
defer func() { _ = resp.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp2.StatusCode, respBody,
resp.StatusCode, respBody,
)
}
var result map[string]any
// Extract auth cookie from login response.
var loginCookie string
_ = json.NewDecoder(resp2.Body).Decode(&result)
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
loginCookie = cookie.Value
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
break
}
}
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
if loginCookie == "" {
t.Fatal("expected auth cookie from login")
}
status, state := tserver.getState(token)
// Verify login cookie works for auth.
status, state := tserver.getState(loginCookie)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
@@ -2051,49 +2019,22 @@ func TestLoginValid(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
// Create session and set password via PASS command.
token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"correctpass1"},
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "wrongpwuser",
"password": "wrongpass12",
},
http.StatusUnauthorized,
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
@@ -2109,13 +2050,74 @@ func TestLoginNonexistentUser(t *testing.T) {
)
}
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{"nick": "cookietest"},
)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(
"expected 201, got %d", resp.StatusCode,
)
}
// Verify Set-Cookie header.
var authCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
authCookie = cookie
break
}
}
if authCookie == nil {
t.Fatal("expected neoirc_auth cookie")
}
if !authCookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if authCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie should be SameSite=Strict")
}
// Verify JSON body does NOT contain token.
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if _, hasToken := result["token"]; hasToken {
t.Fatal("JSON body should not contain token")
}
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected token for anonymous session")
t.Fatal("expected cookie for anonymous session")
}
status, state := tserver.getState(token)

View File

@@ -5,120 +5,11 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
@@ -195,9 +86,66 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
"id": sessionID,
"nick": payload.Nick,
}, http.StatusOK)
}
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -126,18 +126,23 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
}
// CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowedOrigins: []string{"*"},
AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
"Accept", "Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
AllowCredentials: true,
MaxAge: corsMaxAge,
})
}

View File

@@ -75,10 +75,6 @@ func (srv *Server) setupAPIv1(router chi.Router) {
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),

View File

@@ -11,6 +11,7 @@ const (
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"