refactor: replace Bearer token auth with HttpOnly cookies #84

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

454
README.md
View File

@@ -115,7 +115,7 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks 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
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 ### 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 IRC's command model is correct and only the transport and session management
need to change. need to change.
### Identity & Sessions — Dual Authentication Model ### Identity & Sessions — Cookie-Based Authentication
The server supports two authentication paths: **anonymous sessions** for The server uses **HTTP cookies** for all authentication. There is no separate
instant access, and **optional account registration** for multi-client access. registration step — sessions start anonymous and can optionally set a password
for multi-client access.
#### Anonymous Sessions (No Account Required) #### Session Creation
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 assigns an **auth token** (64 hex characters of nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
cryptographically random bytes) and returns the user ID, nick, and token. a cryptographically random token (64 hex characters) and returns the user
- The auth token implicitly identifies the client. Clients present it via ID and nick in the JSON response body. No token appears in the JSON body.
`Authorization: Bearer <token>`. - The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
- Anonymous sessions are ephemeral — when the session expires or the user Clients never need to handle the token directly — the browser/HTTP client
QUITs, the nick is released and there is no way to reclaim it. 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 - **Set password via IRC PASS command**: the authenticated client sends
password (minimum 8 characters) → server creates a session with the `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
password hashed via bcrypt, and returns the user ID, nick, and auth token. The server hashes the password with bcrypt and stores it on the session.
- **Login**: client sends `POST /api/v1/login` with nick and password → Password must be at least 8 characters.
server verifies the password against the stored bcrypt hash and creates a - **Login from another client**: `POST /api/v1/login` with nick and password →
new client token for the existing session. This enables multi-client server verifies the password, creates a new client for the existing session,
access: logging in from a new device adds a client to the existing session and sets an auth cookie. Channel memberships and message queues are shared.
rather than creating a new one, so channel memberships and message queues Login only works while the session still exists — if all clients have logged
are shared. Note: login only works while the session still exists — if all out or the user has sent QUIT, the session is deleted and the password is
clients have logged out or the user has sent QUIT, the session is deleted lost.
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 (Both Paths) #### Common Properties
- 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.
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded - Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
in the token, no client-side decode. The server is the sole authority on encoded in the token, no client-side decode. The server is the sole authority
token validity. on cookie 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. Optional registration with password enables multi-client login a bouncer. The PASS command enables multi-client login without adding friction
without adding friction for casual users: if you don't want an account, for casual users: if you don't need multi-client, just create a session and
don't create one. Note: in the current implementation, both anonymous and go. Cookie-based auth eliminates token management from client code entirely —
registered sessions are deleted when the last client disconnects (QUIT or browsers and HTTP cookie jars handle it automatically. Note: both anonymous
logout); registration does not make a session survive all-client and password-protected sessions are deleted when the last client disconnects
removal. Identity verification at the message layer via cryptographic (QUIT or logout). Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent signatures (see [Security Model](#security-model)) remains independent of
of account registration. password status.
### Nick Semantics ### 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 - 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 token. identified by the auth cookie.
- 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.
@@ -244,11 +240,10 @@ 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 registered session, enabling true multi-client support client to an existing session (one that has a password set via PASS command),
(multiple tokens sharing one nick/session with independent message queues). enabling true multi-client support (multiple cookies sharing one nick/session
Anonymous sessions created via `POST /api/v1/session` always create a new with independent message queues). Sessions without a password cannot be
user with a new nick. A future endpoint to "add a client to an existing logged into.
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
@@ -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 canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing. 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 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 tokens are simpler: Opaque auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash - Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the raw token; server hashes and looks it up sets raw hex as an HttpOnly cookie
- Revocation is a database delete - 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 - 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 ### Session Lifecycle
#### Anonymous Session #### Session Creation
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client ──────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/session {"nick":"alice"} │ │ 1. POST /api/v1/session {"nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │ │ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │ │ → {"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", │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │ │ "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"} │ │ 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) │ deletes session, releases nick, clears cookie)
│ │ │ │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```
#### Registered Account #### Multi-Client via Password
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client A ────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/register │ 1. POST /api/v1/session {"nick":"alice"}
{"nick":"alice", "password":"s3cret!!"} → Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ...
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │ → {"id":1, "nick":"alice"}
(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) ┐
│ │ │ │
2. POST /api/v1/login │ 3. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │ │ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ...
│ → {"id":1, "nick":"alice"} │
│ (New client added to existing session — channels │ │ (New client added to existing session — channels │
│ and message queues are preserved. If all clients │ and message queues are preserved.)
│ have logged out, session no longer exists.) │
│ │ │ │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```
@@ -729,6 +734,35 @@ 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.
@@ -795,7 +829,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 token is invalidated, the nick - The user's session is destroyed — the auth cookie 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.
@@ -1086,8 +1120,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 `Authorization: Bearer <token>` header. The token is obtained from require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session`. `POST /api/v1/session` and `POST /api/v1/login`.
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:
@@ -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) | | `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"
} }
``` ```
@@ -1128,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|---------|---------|-------------| |---------|---------|-------------|
| `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:**
@@ -1141,66 +1191,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**curl example:** **curl example:**
```bash ```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' \ -H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token) -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
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 previously registered nick and password. Creates a new Authenticate with a nick and password (set via the PASS IRC command). Creates a
client token for the existing session, preserving channel memberships and new client for the existing session, preserving channel memberships and message
message queues. This is how multi-client access works for registered accounts: queues. This is how multi-client access works: each login adds a new client to
each login adds a new client to the session. the session with its own auth cookie and message delivery queue.
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
@@ -1213,37 +1215,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 a registered account | | `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the account's password | | `password` | string | Yes | Must match the session'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 (same as when registered) | | `id` | integer | Session ID |
| `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 account has no password | | 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
**curl example:** **curl example:**
```bash ```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' \ -H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token) -d '{"nick":"alice","password":"mypassword"}'
echo $TOKEN
``` ```
### GET /api/v1/state — Get Session State ### GET /api/v1/state — Get Session State
@@ -1287,13 +1289,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 \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | 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" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/messages — Poll Messages (Long-Poll) ### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1353,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):** **curl example (immediate):**
```bash ```bash
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
**curl example (long-poll, 15s):** **curl example (long-poll, 15s):**
```bash ```bash
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### POST /api/v1/messages — Send Command ### POST /api/v1/messages — Send Command
@@ -1387,6 +1387,7 @@ 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 |
@@ -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 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 tokens (401), and server errors (500). auth cookies (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 token | | 401 | `unauthorized` | Missing or invalid auth cookie |
| 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):**
@@ -1499,11 +1500,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" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | 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" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/channels — List Channels ### GET /api/v1/channels — List Channels
@@ -1534,18 +1535,22 @@ 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 \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### POST /api/v1/logout — Logout ### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the Destroy the current client's session cookie and server-side client record.
session, the user is fully cleaned up: parted from all channels (with QUIT If no other clients remain on the session, the user is fully cleaned up:
broadcast to members), session deleted, nick released. 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` **Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json ```json
{"status": "ok"} {"status": "ok"}
``` ```
@@ -1554,12 +1559,11 @@ broadcast to members), session deleted, nick released.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token | | 401 | `unauthorized` | Missing or invalid auth cookie |
**curl example:** **curl example:**
```bash ```bash
curl -s -X POST http://localhost:8080/api/v1/logout \ curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/users/me — Current User Info ### 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:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/users/me \ curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/server — Server Info ### GET /api/v1/server — Server Info
@@ -1828,20 +1832,21 @@ authenticity.
### Authentication ### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy). - **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
Tokens are hashed (SHA-256) before storage and validated on every request. 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 - **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The token is the sole credential. password, instant access. The auth cookie is the sole credential.
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password - **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
(minimum 8 characters). The password is hashed with bcrypt at the default password on the session. `POST /api/v1/login` authenticates against the
cost factor and stored alongside the session. `POST /api/v1/login` stored hash and issues a new client cookie.
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. Anonymous sessions have handles salting and key stretching automatically. Sessions without a
an empty `password_hash` and cannot be logged into via `/login`. password cannot be logged into via `/login`.
- **Token security**: Tokens should be treated like session cookies. Transmit - **Cookie security**: Auth cookies should only be transmitted over HTTPS in
only over HTTPS in production. If a token is compromised, the attacker has production. If a cookie is compromised, the attacker has full access to the
full access to the session until QUIT or expiry. session until QUIT or expiry.
### Message Integrity ### Message Integrity
@@ -1878,8 +1883,10 @@ 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 by default (`Access-Control-Allow-Origin: *`). - **CORS**: The server allows all origins with credentials
Restrict this in production via reverse proxy configuration if needed. (`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 - **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
@@ -2069,7 +2076,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 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 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
@@ -2230,68 +2237,59 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl ### Step-by-Step with curl
```bash ```bash
# 1a. Create an anonymous session (no account) # 1a. Create a session (cookie saved automatically with -c)
export 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":"testuser"}' | jq -r .token) -d '{"nick":"testuser"}'
# 1b. Or register an account (multi-client support) # 1b. Optionally set a password for multi-client access
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \ -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 # 1c. Login from another device (saves new cookie)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \ curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token) -d '{"nick":"testuser","password":"mypassword"}'
# 2. Join a channel # 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
-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 "http://localhost:8080/api/v1/messages?after=0&timeout=15" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
# 6. Send a DM # 6. Send a DM
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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 -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -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"]}'
``` ```
@@ -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: The key to real-time messaging is the poll loop. Here's the pattern:
```python ```python
# Python example # Python example — using requests.Session for automatic cookie handling
import requests, json import requests, json, time
BASE = "http://localhost:8080/api/v1" BASE = "http://localhost:8080/api/v1"
token = None session = requests.Session() # Manages cookies automatically
last_id = 0 last_id = 0
# Create session # Create session (cookie set automatically via Set-Cookie header)
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"}) resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"] print(f"Session: {resp.json()}")
headers = {"Authorization": f"Bearer {token}"}
# Join channel # Join channel
requests.post(f"{BASE}/messages", headers=headers, session.post(f"{BASE}/messages",
json={"command": "JOIN", "to": "#general"}) json={"command": "JOIN", "to": "#general"})
# Poll loop # Poll loop
while True: while True:
try: try:
resp = requests.get(f"{BASE}/messages", resp = session.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()
@@ -2338,14 +2334,14 @@ while True:
``` ```
```javascript ```javascript
// JavaScript/browser example // JavaScript/browser example — cookies sent automatically
async function pollLoop(token) { async function pollLoop() {
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`,
{headers: {'Authorization': `Bearer ${token}`}} {credentials: 'same-origin'} // Include cookies
); );
if (resp.status === 401) { /* session expired */ break; } if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json(); const data = await resp.json();
@@ -2377,8 +2373,8 @@ Clients should handle these message commands from the queue:
### Error Handling ### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or - **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (registered account). re-login (if a password was set).
- **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).
@@ -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 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 token is invalid. 5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
For anonymous sessions, create a new session. For registered accounts, invalid. For sessions without a password, create a new session. For
log in again via `POST /api/v1/login` to get a fresh token on the same sessions with a password set (via PASS command), log in again via
session. If it fails with a network error, retry with backoff. `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) 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)
- [ ] **Multi-client sessions** — add client to existing session - [x] **Multi-client sessions** — set a password via PASS command, then
(share nick across devices) 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+) ### Future (1.0+)
@@ -2674,13 +2673,12 @@ 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. **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 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), optional account registration (multiple devices sharing one session), the PASS command sets a password
with password is available — but never required. Identity on the session — but it's never required. Identity verification at the
verification at the message layer uses cryptographic signing, message layer uses cryptographic signing, independent of password status.
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,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -28,16 +29,19 @@ 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. // NewClient creates a new API client with a cookie jar
// for automatic auth cookie management.
func NewClient(baseURL string) *Client { func NewClient(baseURL string) *Client {
return &Client{ //nolint:exhaustruct // Token set after CreateSession jar, _ := cookiejar.New(nil)
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,
}, },
} }
} }
@@ -79,8 +83,6 @@ 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
} }
@@ -121,6 +123,7 @@ 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{}
@@ -145,10 +148,6 @@ 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)
@@ -304,12 +303,6 @@ 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,9 +10,8 @@ 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,80 +16,28 @@ var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
// RegisterUser creates a session with a hashed password // SetPassword sets a bcrypt-hashed password on a session,
// and returns session ID, client ID, and token. // enabling multi-client login via POST /api/v1/login.
func (database *Database) RegisterUser( func (database *Database) SetPassword(
ctx context.Context, ctx context.Context,
nick, password string, sessionID int64,
) (int64, int64, string, error) { password string,
) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return fmt.Errorf("hash password: %w", err)
"hash password: %w", err,
)
} }
sessionUUID := uuid.New().String() _, err = database.conn.ExecContext(ctx,
clientUUID := uuid.New().String() "UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
token, err := generateToken()
if err != nil { if err != nil {
return 0, 0, "", err return fmt.Errorf("set password: %w", err)
} }
now := time.Now() return nil
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,63 +6,65 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func TestRegisterUser(t *testing.T) { func TestSetPassword(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, clientID, token, err := sessionID, _, _, err :=
database.RegisterUser(ctx, "reguser", "password123") database.CreateSession(ctx, "passuser")
if err != nil { if err != nil {
t.Fatal(err) 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") 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() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
regSID, regCID, regToken, err := sessionID, _, _, err :=
database.RegisterUser(ctx, "dupnick", "password123") database.CreateSession(ctx, "wrongpw")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = regSID err = database.SetPassword(
_ = regCID ctx, sessionID, "correctpass",
_ = regToken )
if err != nil {
dupSID, dupCID, dupToken, dupErr := t.Fatal(err)
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
} }
_ = dupSID loginSID, loginCID, loginToken, loginErr :=
_ = dupCID database.LoginUser(ctx, "wrongpw", "wrongpass12")
_ = dupToken if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
} }
func TestLoginUser(t *testing.T) { func TestLoginUser(t *testing.T) {
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
regSID, regCID, regToken, err := sessionID, _, _, err :=
database.RegisterUser(ctx, "loginuser", "mypassword") database.CreateSession(ctx, "loginuser")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = regSID err = database.SetPassword(
_ = regCID ctx, sessionID, "mypassword",
_ = regToken )
if err != nil {
t.Fatal(err)
}
sessionID, clientID, token, err := loginSID, loginCID, 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 sessionID == 0 || clientID == 0 || token == "" { if loginSID == 0 || loginCID == 0 || token == "" {
t.Fatal("expected valid ids and 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) { func TestLoginUserNoPassword(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -36,6 +36,7 @@ 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 {
@@ -46,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize return defaultMaxBodySize
} }
// authSession extracts the session from the client token. // authSession extracts the session from the auth cookie.
func (hdlr *Handlers) authSession( func (hdlr *Handlers) authSession(
request *http.Request, request *http.Request,
) (int64, int64, string, error) { ) (int64, int64, string, error) {
auth := request.Header.Get("Authorization") cookie, err := request.Cookie(authCookieName)
if !strings.HasPrefix(auth, "Bearer ") { if err != nil || cookie.Value == "" {
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(), token, request.Context(), cookie.Value,
) )
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err) return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -71,6 +67,46 @@ 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,
@@ -226,10 +262,11 @@ 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)
} }
@@ -875,6 +912,11 @@ 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,
@@ -2005,6 +2047,8 @@ 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)
@@ -2807,6 +2851,8 @@ 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,15 +33,16 @@ 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.
@@ -261,7 +262,7 @@ func doRequest(
func doRequestAuth( func doRequestAuth(
t *testing.T, t *testing.T,
method, url, token string, method, url, cookie string,
body io.Reader, body io.Reader,
) (*http.Response, error) { ) (*http.Response, error) {
t.Helper() t.Helper()
@@ -279,10 +280,11 @@ func doRequestAuth(
) )
} }
if token != "" { if cookie != "" {
request.Header.Set( request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
"Authorization", "Bearer "+token, Name: authCookieName,
) Value: cookie,
})
} }
resp, err := http.DefaultClient.Do(request) resp, err := http.DefaultClient.Do(request)
@@ -325,17 +327,19 @@ func (tserver *testServer) createSession(
) )
} }
var result struct { // Drain the body.
ID int64 `json:"id"` _, _ = io.ReadAll(resp.Body)
Token string `json:"token"`
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
} }
decErr := json.NewDecoder(resp.Body).Decode(&result) tserver.t.Fatal("no auth cookie in response")
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
return result.Token return ""
} }
func (tserver *testServer) sendCommand( func (tserver *testServer) sendCommand(
@@ -492,10 +496,10 @@ func findNumeric(
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("alice") cookie := tserver.createSession("alice")
if token == "" { if cookie == "" {
t.Fatal("expected token") 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) tserver := newTestServer(t)
status, _ := tserver.getState("") 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) tserver := newTestServer(t)
status, _ := tserver.getState( status, _ := tserver.getState(
"invalid-token-12345", "invalid-cookie-12345",
) )
if status != http.StatusUnauthorized { if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status) 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( func postJSONExpectStatus(
t *testing.T, t *testing.T,
tserver *testServer, tserver *testServer,
@@ -1945,36 +1865,102 @@ func postJSONExpectStatus(
} }
} }
func TestRegisterShortPassword(t *testing.T) { func TestPassCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("passuser")
postJSONExpectStatus( // Drain initial messages.
t, tserver, "/api/v1/register", _, _ = tserver.pollMessages(token, 0)
map[string]string{
"nick": "shortpw", "password": "short", // 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) tserver := newTestServer(t)
token := tserver.createSession("shortpw")
postJSONExpectStatus( // Drain initial messages.
t, tserver, "/api/v1/register", _, lastID := tserver.pollMessages(token, 0)
map[string]string{
"nick": "bad nick!", // Try short password — should fail.
"password": "password123", 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) { func TestLoginValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Register first. // Create session and set password via PASS command.
regBody, err := json.Marshal(map[string]string{ 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", "nick": "loginuser", "password": "password123",
}) })
if err != nil { if err != nil {
@@ -1982,26 +1968,6 @@ 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"),
@@ -2011,31 +1977,33 @@ func TestLoginValid(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer func() { _ = resp2.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp2.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body) respBody, _ := io.ReadAll(resp.Body)
t.Fatalf( t.Fatalf(
"expected 200, got %d: %s", "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"] == "" { break
t.Fatal("expected token in response") }
} }
// Verify token works. if loginCookie == "" {
token, ok := result["token"].(string) t.Fatal("expected auth cookie from login")
if !ok {
t.Fatal("token not a string")
} }
status, state := tserver.getState(token) // Verify login cookie works for auth.
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)
} }
@@ -2051,49 +2019,22 @@ func TestLoginValid(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) { func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{ // Create session and set password via PASS command.
"nick": "wrongpwuser", "password": "correctpass1", token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"correctpass1"},
}) })
if err != nil {
t.Fatal(err)
}
resp, err := doRequest( postJSONExpectStatus(
t, t, tserver, "/api/v1/login",
http.MethodPost, map[string]string{
tserver.url("/api/v1/register"), "nick": "wrongpwuser",
bytes.NewReader(regBody), "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) { 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) { 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 token for anonymous session") t.Fatal("expected cookie for anonymous session")
} }
status, state := tserver.getState(token) status, state := tserver.getState(token)

View File

@@ -5,120 +5,11 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/pkg/irc"
) )
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(
@@ -195,9 +86,66 @@ 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,18 +126,23 @@ 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
AllowedOrigins: []string{"*"}, AllowOriginFunc: func(
_ *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", "Authorization", "Accept", "Content-Type", "X-CSRF-Token",
"Content-Type", "X-CSRF-Token",
}, },
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: false, AllowCredentials: true,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }

View File

@@ -75,10 +75,6 @@ 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,6 +11,7 @@ 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"