diff --git a/README.md b/README.md index cc92ba5..73c3ad2 100644 --- a/README.md +++ b/README.md @@ -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 `. -- 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=; 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=; 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=; 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 ` 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 | 1–32 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 diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 74561ab..50a5b2b 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -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) diff --git a/internal/cli/api/types.go b/internal/cli/api/types.go index b9ae32e..8c7e50c 100644 --- a/internal/cli/api/types.go +++ b/internal/cli/api/types.go @@ -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. diff --git a/internal/db/auth.go b/internal/db/auth.go index 7bf18bd..4f9dc61 100644 --- a/internal/db/auth.go +++ b/internal/db/auth.go @@ -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 diff --git a/internal/db/auth_test.go b/internal/db/auth_test.go index 5188925..8090d30 100644 --- a/internal/db/auth_test.go +++ b/internal/db/auth_test.go @@ -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() diff --git a/internal/handlers/api.go b/internal/handlers/api.go index b8242e7..5d6822f 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index ddb5b3e..2d2cd50 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 293636f..363f0b6 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 87305dd..61ceb28 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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, }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 0b79bdc..5ccbaee 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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(), diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index fc2191b..f2da975 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -11,6 +11,7 @@ const ( CmdNames = "NAMES" CmdNick = "NICK" CmdNotice = "NOTICE" + CmdPass = "PASS" CmdPart = "PART" CmdPing = "PING" CmdPong = "PONG"