All checks were successful
check / check (push) Successful in 2m34s
## Summary
Major auth refactor replacing Bearer token authentication with HttpOnly cookie-based auth, removing the registration endpoint, and adding the PASS IRC command for password management.
## Changes
### Removed
- `POST /api/v1/register` endpoint (no separate registration path)
- `RegisterUser` DB method
- `Authorization: Bearer` header parsing
- `token` field from all JSON response bodies
- `Token` field from CLI `SessionResponse` type
### Added
- **Cookie-based authentication**: `neoirc_auth` HttpOnly cookie set on session creation and login
- **PASS IRC command**: set a password on the authenticated session via `POST /api/v1/messages {"command":"PASS","body":["password"]}` (minimum 8 characters)
- `SetPassword` DB method (bcrypt hashing)
- Cookie helpers: `setAuthCookie()`, `clearAuthCookie()`
- Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS, Path=/
- CORS updated: `AllowCredentials: true` with origin reflection function
### Auth Flow
1. `POST /api/v1/session {"nick":"alice"}` → sets `neoirc_auth` cookie, returns `{"id":1,"nick":"alice"}`
2. (Optional) `POST /api/v1/messages {"command":"PASS","body":["s3cret"]}` → sets password for multi-client
3. Another client: `POST /api/v1/login {"nick":"alice","password":"s3cret"}` → sets `neoirc_auth` cookie
4. Logout and QUIT clear the cookie
### Tests
- All existing tests updated to use cookies instead of Bearer tokens
- New tests: `TestPassCommand`, `TestPassCommandShortPassword`, `TestPassCommandEmpty`, `TestSessionCookie`
- Register tests removed
- Login tests updated to use session creation + PASS command flow
### README
- Extensively updated: auth model documentation, API reference, curl examples, security model, design principles, roadmap
- All Bearer token references replaced with cookie-based auth
- Register endpoint documentation removed
- PASS command documented
### CI
- `docker build .` passes (format check, lint, all tests, build)
closes #83
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #84
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
327 lines
6.1 KiB
Go
327 lines
6.1 KiB
Go
// Package neoircapi provides a client for the neoirc server API.
|
|
package neoircapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
)
|
|
|
|
const (
|
|
httpTimeout = 30 * time.Second
|
|
pollExtraTime = 5
|
|
httpErrThreshold = 400
|
|
)
|
|
|
|
var errHTTP = errors.New("HTTP error")
|
|
|
|
// Client wraps HTTP calls to the neoirc server API.
|
|
type Client struct {
|
|
BaseURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new API client with a cookie jar
|
|
// for automatic auth cookie management.
|
|
func NewClient(baseURL string) *Client {
|
|
jar, _ := cookiejar.New(nil)
|
|
|
|
return &Client{
|
|
BaseURL: baseURL,
|
|
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
|
Timeout: httpTimeout,
|
|
Jar: jar,
|
|
},
|
|
}
|
|
}
|
|
|
|
// CreateSession creates a new session on the server.
|
|
// If the server requires hashcash proof-of-work, it
|
|
// automatically fetches the difficulty and computes a
|
|
// valid stamp.
|
|
func (client *Client) CreateSession(
|
|
nick string,
|
|
) (*SessionResponse, error) {
|
|
// Fetch server info to check for hashcash requirement.
|
|
info, err := client.GetServerInfo()
|
|
|
|
var hashcashStamp string
|
|
|
|
if err == nil && info.HashcashBits > 0 {
|
|
resource := info.Name
|
|
if resource == "" {
|
|
resource = "neoirc"
|
|
}
|
|
|
|
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
|
}
|
|
|
|
data, err := client.do(
|
|
http.MethodPost,
|
|
"/api/v1/session",
|
|
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp SessionResponse
|
|
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode session: %w", err)
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// GetState returns the current user state.
|
|
func (client *Client) GetState() (*StateResponse, error) {
|
|
data, err := client.do(
|
|
http.MethodGet, "/api/v1/state", nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp StateResponse
|
|
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode state: %w", err)
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// SendMessage sends a message (any IRC command).
|
|
func (client *Client) SendMessage(msg *Message) error {
|
|
_, err := client.do(
|
|
http.MethodPost, "/api/v1/messages", msg,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// PollMessages long-polls for new messages.
|
|
func (client *Client) PollMessages(
|
|
afterID int64,
|
|
timeout int,
|
|
) (*PollResult, error) {
|
|
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine
|
|
Timeout: time.Duration(
|
|
timeout+pollExtraTime,
|
|
) * time.Second,
|
|
Jar: client.HTTPClient.Jar,
|
|
}
|
|
|
|
params := url.Values{}
|
|
if afterID > 0 {
|
|
params.Set(
|
|
"after",
|
|
strconv.FormatInt(afterID, 10),
|
|
)
|
|
}
|
|
|
|
params.Set("timeout", strconv.Itoa(timeout))
|
|
|
|
path := "/api/v1/messages?" + params.Encode()
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
client.BaseURL+path,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
|
|
resp, err := pollClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("poll request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read poll body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= httpErrThreshold {
|
|
return nil, fmt.Errorf(
|
|
"%w %d: %s",
|
|
errHTTP, resp.StatusCode, string(data),
|
|
)
|
|
}
|
|
|
|
var wrapped MessagesResponse
|
|
|
|
err = json.Unmarshal(data, &wrapped)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"decode messages: %w", err,
|
|
)
|
|
}
|
|
|
|
return &PollResult{
|
|
Messages: wrapped.Messages,
|
|
LastID: wrapped.LastID,
|
|
}, nil
|
|
}
|
|
|
|
// JoinChannel joins a channel.
|
|
func (client *Client) JoinChannel(channel string) error {
|
|
return client.SendMessage(
|
|
&Message{ //nolint:exhaustruct // only command+to needed
|
|
Command: irc.CmdJoin, To: channel,
|
|
},
|
|
)
|
|
}
|
|
|
|
// PartChannel leaves a channel.
|
|
func (client *Client) PartChannel(channel string) error {
|
|
return client.SendMessage(
|
|
&Message{ //nolint:exhaustruct // only command+to needed
|
|
Command: irc.CmdPart, To: channel,
|
|
},
|
|
)
|
|
}
|
|
|
|
// ListChannels returns all channels on the server.
|
|
func (client *Client) ListChannels() (
|
|
[]Channel, error,
|
|
) {
|
|
data, err := client.do(
|
|
http.MethodGet, "/api/v1/channels", nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var channels []Channel
|
|
|
|
err = json.Unmarshal(data, &channels)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"decode channels: %w", err,
|
|
)
|
|
}
|
|
|
|
return channels, nil
|
|
}
|
|
|
|
// GetMembers returns members of a channel.
|
|
func (client *Client) GetMembers(
|
|
channel string,
|
|
) ([]string, error) {
|
|
name := strings.TrimPrefix(channel, "#")
|
|
|
|
data, err := client.do(
|
|
http.MethodGet,
|
|
"/api/v1/channels/"+url.PathEscape(name)+
|
|
"/members",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var members []string
|
|
|
|
err = json.Unmarshal(data, &members)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"unexpected members format: %w", err,
|
|
)
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
// GetServerInfo returns server info.
|
|
func (client *Client) GetServerInfo() (
|
|
*ServerInfo, error,
|
|
) {
|
|
data, err := client.do(
|
|
http.MethodGet, "/api/v1/server", nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info ServerInfo
|
|
|
|
err = json.Unmarshal(data, &info)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"decode server info: %w", err,
|
|
)
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
func (client *Client) do(
|
|
method, path string,
|
|
body any,
|
|
) ([]byte, error) {
|
|
var bodyReader io.Reader
|
|
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal: %w", err)
|
|
}
|
|
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
context.Background(),
|
|
method,
|
|
client.BaseURL+path,
|
|
bodyReader,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request: %w", err)
|
|
}
|
|
|
|
request.Header.Set(
|
|
"Content-Type", "application/json",
|
|
)
|
|
|
|
resp, err := client.HTTPClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= httpErrThreshold {
|
|
return data, fmt.Errorf(
|
|
"%w %d: %s",
|
|
errHTTP, resp.StatusCode, string(data),
|
|
)
|
|
}
|
|
|
|
return data, nil
|
|
}
|