Files
chat/internal/handlers/auth.go
clawbot 5f3c0633f6
All checks were successful
check / check (push) Successful in 2m34s
refactor: replace Bearer token auth with HttpOnly cookies (#84)
## 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>
2026-03-20 23:54:23 +01:00

159 lines
3.0 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const minPasswordLength = 8
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleLogin(writer, request)
}
}
func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter,
request *http.Request,
) {
type loginRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload loginRequest
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 payload.Nick == "" || payload.Password == "" {
hdlr.respondError(
writer, request,
"nick and password required",
http.StatusBadRequest,
)
return
}
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
remoteIP, hostname,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid credentials",
http.StatusUnauthorized,
)
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
request, clientID, sessionID, payload.Nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
}, http.StatusOK)
}
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}