1 Commits

Author SHA1 Message Date
user
68558ce5b5 feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 1m11s
Add per-channel hashcash requirement via MODE +H <bits>. When set,
PRIVMSG to the channel must include a valid hashcash stamp in the
meta.hashcash field bound to the channel name and message body hash.

Server validates stamp format, difficulty, date freshness, channel
binding, body hash binding, and proof-of-work. Spent stamps are
persisted to SQLite with 1-year TTL for replay prevention.

Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter

Changes:
- Schema: add hashcash_bits column to channels, spent_hashcash table
- DB: queries for get/set channel hashcash bits, spent token CRUD
- Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp
- Handlers: validate hashcash on PRIVMSG, MODE +H/-H support
- Pass meta through fanOut chain to store in messages
- Prune spent hashcash tokens in cleanup loop (1-year TTL)
- Client: MintChannelHashcash helper for CLI
- Tests: 12 new channel_test.go + 10 new api_test.go integration tests
- README: document +H mode, stamp format, and usage
2026-03-17 04:53:33 -07:00
11 changed files with 756 additions and 677 deletions

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

View File

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

View File

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

View File

@@ -16,28 +16,80 @@ var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
// SetPassword sets a bcrypt-hashed password on a session, // RegisterUser creates a session with a hashed password
// enabling multi-client login via POST /api/v1/login. // and returns session ID, client ID, and token.
func (database *Database) SetPassword( func (database *Database) RegisterUser(
ctx context.Context, ctx context.Context,
sessionID int64, nick, password string,
password string, ) (int64, int64, string, error) {
) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
} }
_, err = database.conn.ExecContext(ctx, sessionUUID := uuid.New().String()
"UPDATE sessions SET password_hash = ? WHERE id = ?", clientUUID := uuid.New().String()
string(hash), sessionID)
token, err := generateToken()
if err != nil { if err != nil {
return fmt.Errorf("set password: %w", err) return 0, 0, "", err
} }
return nil 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
} }
// LoginUser verifies a nick/password and creates a new // LoginUser verifies a nick/password and creates a new

View File

@@ -6,65 +6,63 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func TestSetPassword(t *testing.T) { func TestRegisterUser(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := sessionID, clientID, token, err :=
database.CreateSession(ctx, "passuser") database.RegisterUser(ctx, "reguser", "password123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( if sessionID == 0 || clientID == 0 || token == "" {
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") 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 TestSetPasswordThenWrongLogin(t *testing.T) { func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := regSID, regCID, regToken, err :=
database.CreateSession(ctx, "wrongpw") database.RegisterUser(ctx, "dupnick", "password123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( _ = regSID
ctx, sessionID, "correctpass", _ = regCID
) _ = regToken
if err != nil {
t.Fatal(err) dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
} }
loginSID, loginCID, loginToken, loginErr := _ = dupSID
database.LoginUser(ctx, "wrongpw", "wrongpass12") _ = dupCID
if loginErr == nil { _ = dupToken
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
} }
func TestLoginUser(t *testing.T) { func TestLoginUser(t *testing.T) {
@@ -73,26 +71,23 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := regSID, regCID, regToken, err :=
database.CreateSession(ctx, "loginuser") database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( _ = regSID
ctx, sessionID, "mypassword", _ = regCID
) _ = regToken
if err != nil {
t.Fatal(err)
}
loginSID, loginCID, token, err := sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword") database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if loginSID == 0 || loginCID == 0 || token == "" { if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token") t.Fatal("expected valid ids and token")
} }
@@ -108,6 +103,33 @@ 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) { func TestLoginUserNoPassword(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -36,7 +36,6 @@ const (
defaultMaxBodySize = 4096 defaultMaxBodySize = 4096
defaultHistLimit = 50 defaultHistLimit = 50
maxHistLimit = 500 maxHistLimit = 500
authCookieName = "neoirc_auth"
) )
func (hdlr *Handlers) maxBodySize() int64 { func (hdlr *Handlers) maxBodySize() int64 {
@@ -47,18 +46,23 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize return defaultMaxBodySize
} }
// authSession extracts the session from the auth cookie. // authSession extracts the session from the client token.
func (hdlr *Handlers) authSession( func (hdlr *Handlers) authSession(
request *http.Request, request *http.Request,
) (int64, int64, string, error) { ) (int64, int64, string, error) {
cookie, err := request.Cookie(authCookieName) auth := request.Header.Get("Authorization")
if err != nil || cookie.Value == "" { if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized return 0, 0, "", errUnauthorized
} }
sessionID, clientID, nick, err := sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken( hdlr.params.Database.GetSessionByToken(
request.Context(), cookie.Value, request.Context(), token,
) )
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err) return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -67,46 +71,6 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil 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( func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -262,11 +226,10 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,
"token": token,
}, http.StatusCreated) }, http.StatusCreated)
} }
@@ -912,11 +875,6 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, bodyLines,
) )
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
@@ -2047,8 +2005,6 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID, request.Context(), sessionID,
) )
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"}, map[string]string{"status": "quit"},
http.StatusOK) http.StatusOK)
@@ -2851,8 +2807,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)

View File

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

View File

@@ -5,11 +5,120 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 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. // HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc { func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func( return func(
@@ -86,66 +195,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,
"token": token,
}, http.StatusOK) }, 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,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
} }
// CORS returns middleware that handles Cross-Origin Resource Sharing. // 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 { func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowOriginFunc: func( AllowedOrigins: []string{"*"},
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{ AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS", "GET", "POST", "PUT", "DELETE", "OPTIONS",
}, },
AllowedHeaders: []string{ AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token", "Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
}, },
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }

View File

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

View File

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