refactor: replace Bearer token auth with HttpOnly cookies (#84)
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>
This commit was merged in pull request #84.
This commit is contained in:
2026-03-20 23:54:23 +01:00
committed by Jeffrey Paul
parent db3d23c224
commit 5f3c0633f6
11 changed files with 657 additions and 917 deletions

View File

@@ -40,15 +40,16 @@ func TestMain(m *testing.M) {
}
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.
@@ -267,7 +268,7 @@ func doRequest(
func doRequestAuth(
t *testing.T,
method, url, token string,
method, url, cookie string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
@@ -285,10 +286,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)
@@ -331,17 +333,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(
@@ -498,10 +502,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")
}
}
@@ -623,7 +627,7 @@ func TestCreateSessionMalformed(t *testing.T) {
}
}
func TestAuthNoHeader(t *testing.T) {
func TestAuthNoCookie(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
@@ -632,11 +636,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)
@@ -1833,90 +1837,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,
@@ -1951,36 +1871,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 {
@@ -1988,26 +1974,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"),
@@ -2017,31 +1983,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)
}
@@ -2057,49 +2025,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) {
@@ -2115,13 +2056,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)
@@ -2228,7 +2230,7 @@ func TestWhoisShowsHostInfo(t *testing.T) {
}
// createSessionWithUsername creates a session with a
// specific username and returns the token.
// specific username and returns the auth cookie value.
func (tserver *testServer) createSessionWithUsername(
nick, username string,
) string {
@@ -2262,13 +2264,19 @@ func (tserver *testServer) createSessionWithUsername(
)
}
var result struct {
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
}
}
_ = json.NewDecoder(resp.Body).Decode(&result)
tserver.t.Fatal("no auth cookie in response")
return result.Token
return ""
}
func TestWhoShowsHostInfo(t *testing.T) {