refactor: replace Bearer token auth with HttpOnly cookies
All checks were successful
check / check (push) Successful in 2m21s
All checks were successful
check / check (push) Successful in 2m21s
- Remove POST /api/v1/register endpoint entirely - Session creation (POST /api/v1/session) now sets neoirc_auth HttpOnly cookie instead of returning token in JSON body - Login (POST /api/v1/login) now sets neoirc_auth HttpOnly cookie instead of returning token in JSON body - Add PASS IRC command for setting session password (enables multi-client login via POST /api/v1/login) - All per-request auth reads from neoirc_auth cookie instead of Authorization: Bearer header - Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS - Logout and QUIT clear the auth cookie - Update CORS to AllowCredentials:true with origin reflection - Remove Authorization from CORS AllowedHeaders - Update CLI client to use cookie jar (net/http/cookiejar) - Remove Token field from SessionResponse - Add SetPassword to DB layer, remove RegisterUser - Comprehensive test updates for cookie-based auth - Add tests: TestPassCommand, TestPassCommandShortPassword, TestPassCommandEmpty, TestSessionCookie - Update README extensively: auth model, API reference, curl examples, security model, design principles, roadmap closes #83
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -28,16 +29,19 @@ var errHTTP = errors.New("HTTP error")
|
||||
// Client wraps HTTP calls to the neoirc server API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client.
|
||||
// NewClient creates a new API client with a cookie jar
|
||||
// for automatic auth cookie management.
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||
Timeout: httpTimeout,
|
||||
Jar: jar,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,8 +83,6 @@ func (client *Client) CreateSession(
|
||||
return nil, fmt.Errorf("decode session: %w", err)
|
||||
}
|
||||
|
||||
client.Token = resp.Token
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -121,6 +123,7 @@ func (client *Client) PollMessages(
|
||||
Timeout: time.Duration(
|
||||
timeout+pollExtraTime,
|
||||
) * time.Second,
|
||||
Jar: client.HTTPClient.Jar,
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
@@ -145,10 +148,6 @@ func (client *Client) PollMessages(
|
||||
return nil, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set(
|
||||
"Authorization", "Bearer "+client.Token,
|
||||
)
|
||||
|
||||
resp, err := pollClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request: %w", err)
|
||||
@@ -304,12 +303,6 @@ func (client *Client) do(
|
||||
"Content-Type", "application/json",
|
||||
)
|
||||
|
||||
if client.Token != "" {
|
||||
request.Header.Set(
|
||||
"Authorization", "Bearer "+client.Token,
|
||||
)
|
||||
}
|
||||
|
||||
resp, err := client.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http: %w", err)
|
||||
|
||||
@@ -10,9 +10,8 @@ type SessionRequest struct {
|
||||
|
||||
// SessionResponse is the response from session creation.
|
||||
type SessionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Nick string `json:"nick"`
|
||||
Token string `json:"token"`
|
||||
ID int64 `json:"id"`
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
|
||||
// StateResponse is the response from GET /api/v1/state.
|
||||
|
||||
@@ -16,80 +16,28 @@ var errNoPassword = errors.New(
|
||||
"account has no password set",
|
||||
)
|
||||
|
||||
// RegisterUser creates a session with a hashed password
|
||||
// and returns session ID, client ID, and token.
|
||||
func (database *Database) RegisterUser(
|
||||
// SetPassword sets a bcrypt-hashed password on a session,
|
||||
// enabling multi-client login via POST /api/v1/login.
|
||||
func (database *Database) SetPassword(
|
||||
ctx context.Context,
|
||||
nick, password string,
|
||||
) (int64, int64, string, error) {
|
||||
sessionID int64,
|
||||
password string,
|
||||
) error {
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password), bcryptCost,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"hash password: %w", err,
|
||||
)
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
sessionUUID := uuid.New().String()
|
||||
clientUUID := uuid.New().String()
|
||||
|
||||
token, err := generateToken()
|
||||
_, err = database.conn.ExecContext(ctx,
|
||||
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
||||
string(hash), sessionID)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"begin tx: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, password_hash,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, string(hash), now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"create session: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
sessionID, _ := res.LastInsertId()
|
||||
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
clientRes, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"create client: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
clientID, _ := clientRes.LastInsertId()
|
||||
|
||||
err = transaction.Commit()
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"commit registration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return sessionID, clientID, token, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginUser verifies a nick/password and creates a new
|
||||
|
||||
@@ -6,63 +6,65 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestRegisterUser(t *testing.T) {
|
||||
func TestSetPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
database.RegisterUser(ctx, "reguser", "password123")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "passuser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sessionID == 0 || clientID == 0 || token == "" {
|
||||
err = database.SetPassword(
|
||||
ctx, sessionID, "password123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify we can now log in with the password.
|
||||
loginSID, loginCID, loginToken, err :=
|
||||
database.LoginUser(ctx, "passuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loginSID == 0 || loginCID == 0 || loginToken == "" {
|
||||
t.Fatal("expected valid ids and token")
|
||||
}
|
||||
|
||||
// Verify session works via token lookup.
|
||||
sid, cid, nick, err :=
|
||||
database.GetSessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sid != sessionID || cid != clientID {
|
||||
t.Fatal("session/client id mismatch")
|
||||
}
|
||||
|
||||
if nick != "reguser" {
|
||||
t.Fatalf("expected reguser, got %s", nick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "dupnick", "password123")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "wrongpw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = regSID
|
||||
_ = regCID
|
||||
_ = regToken
|
||||
|
||||
dupSID, dupCID, dupToken, dupErr :=
|
||||
database.RegisterUser(ctx, "dupnick", "other12345")
|
||||
if dupErr == nil {
|
||||
t.Fatal("expected error for duplicate nick")
|
||||
err = database.SetPassword(
|
||||
ctx, sessionID, "correctpass",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = dupSID
|
||||
_ = dupCID
|
||||
_ = dupToken
|
||||
loginSID, loginCID, loginToken, loginErr :=
|
||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||
if loginErr == nil {
|
||||
t.Fatal("expected error for wrong password")
|
||||
}
|
||||
|
||||
_ = loginSID
|
||||
_ = loginCID
|
||||
_ = loginToken
|
||||
}
|
||||
|
||||
func TestLoginUser(t *testing.T) {
|
||||
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
||||
sessionID, _, _, err :=
|
||||
database.CreateSession(ctx, "loginuser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = regSID
|
||||
_ = regCID
|
||||
_ = regToken
|
||||
err = database.SetPassword(
|
||||
ctx, sessionID, "mypassword",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
loginSID, loginCID, token, err :=
|
||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sessionID == 0 || clientID == 0 || token == "" {
|
||||
if loginSID == 0 || loginCID == 0 || token == "" {
|
||||
t.Fatal("expected valid ids and token")
|
||||
}
|
||||
|
||||
@@ -103,33 +108,6 @@ func TestLoginUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserWrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = regSID
|
||||
_ = regCID
|
||||
_ = regToken
|
||||
|
||||
loginSID, loginCID, loginToken, loginErr :=
|
||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||
if loginErr == nil {
|
||||
t.Fatal("expected error for wrong password")
|
||||
}
|
||||
|
||||
_ = loginSID
|
||||
_ = loginCID
|
||||
_ = loginToken
|
||||
}
|
||||
|
||||
func TestLoginUserNoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
defaultMaxBodySize = 4096
|
||||
defaultHistLimit = 50
|
||||
maxHistLimit = 500
|
||||
authCookieName = "neoirc_auth"
|
||||
)
|
||||
|
||||
func (hdlr *Handlers) maxBodySize() int64 {
|
||||
@@ -46,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
|
||||
return defaultMaxBodySize
|
||||
}
|
||||
|
||||
// authSession extracts the session from the client token.
|
||||
// authSession extracts the session from the auth cookie.
|
||||
func (hdlr *Handlers) authSession(
|
||||
request *http.Request,
|
||||
) (int64, int64, string, error) {
|
||||
auth := request.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return 0, 0, "", errUnauthorized
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
if token == "" {
|
||||
cookie, err := request.Cookie(authCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return 0, 0, "", errUnauthorized
|
||||
}
|
||||
|
||||
sessionID, clientID, nick, err :=
|
||||
hdlr.params.Database.GetSessionByToken(
|
||||
request.Context(), token,
|
||||
request.Context(), cookie.Value,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf("auth: %w", err)
|
||||
@@ -71,6 +67,46 @@ func (hdlr *Handlers) authSession(
|
||||
return sessionID, clientID, nick, nil
|
||||
}
|
||||
|
||||
// setAuthCookie sets the authentication cookie on the
|
||||
// response.
|
||||
func (hdlr *Handlers) setAuthCookie(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
token string,
|
||||
) {
|
||||
secure := request.TLS != nil ||
|
||||
request.Header.Get("X-Forwarded-Proto") == "https"
|
||||
|
||||
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
||||
Name: authCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// clearAuthCookie removes the authentication cookie from
|
||||
// the client.
|
||||
func (hdlr *Handlers) clearAuthCookie(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
secure := request.TLS != nil ||
|
||||
request.Header.Get("X-Forwarded-Proto") == "https"
|
||||
|
||||
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
||||
Name: authCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) requireAuth(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -226,10 +262,11 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.setAuthCookie(writer, request, token)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
"token": token,
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
|
||||
@@ -875,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdPass:
|
||||
hdlr.handlePass(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdTopic:
|
||||
hdlr.handleTopic(
|
||||
writer, request,
|
||||
@@ -2005,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
|
||||
request.Context(), sessionID,
|
||||
)
|
||||
|
||||
hdlr.clearAuthCookie(writer, request)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "quit"},
|
||||
http.StatusOK)
|
||||
@@ -2807,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.clearAuthCookie(writer, request)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
|
||||
@@ -33,15 +33,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
commandKey = "command"
|
||||
bodyKey = "body"
|
||||
toKey = "to"
|
||||
statusKey = "status"
|
||||
privmsgCmd = "PRIVMSG"
|
||||
joinCmd = "JOIN"
|
||||
apiMessages = "/api/v1/messages"
|
||||
apiSession = "/api/v1/session"
|
||||
apiState = "/api/v1/state"
|
||||
commandKey = "command"
|
||||
bodyKey = "body"
|
||||
toKey = "to"
|
||||
statusKey = "status"
|
||||
privmsgCmd = "PRIVMSG"
|
||||
joinCmd = "JOIN"
|
||||
apiMessages = "/api/v1/messages"
|
||||
apiSession = "/api/v1/session"
|
||||
apiState = "/api/v1/state"
|
||||
authCookieName = "neoirc_auth"
|
||||
)
|
||||
|
||||
// testServer wraps a test HTTP server with helpers.
|
||||
@@ -261,7 +262,7 @@ func doRequest(
|
||||
|
||||
func doRequestAuth(
|
||||
t *testing.T,
|
||||
method, url, token string,
|
||||
method, url, cookie string,
|
||||
body io.Reader,
|
||||
) (*http.Response, error) {
|
||||
t.Helper()
|
||||
@@ -279,10 +280,11 @@ func doRequestAuth(
|
||||
)
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
request.Header.Set(
|
||||
"Authorization", "Bearer "+token,
|
||||
)
|
||||
if cookie != "" {
|
||||
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
|
||||
Name: authCookieName,
|
||||
Value: cookie,
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(request)
|
||||
@@ -325,17 +327,19 @@ func (tserver *testServer) createSession(
|
||||
)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
// Drain the body.
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
|
||||
// Extract auth cookie from response.
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == authCookieName {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
if decErr != nil {
|
||||
tserver.t.Fatalf("decode session: %v", decErr)
|
||||
}
|
||||
tserver.t.Fatal("no auth cookie in response")
|
||||
|
||||
return result.Token
|
||||
return ""
|
||||
}
|
||||
|
||||
func (tserver *testServer) sendCommand(
|
||||
@@ -492,10 +496,10 @@ func findNumeric(
|
||||
|
||||
func TestCreateSessionValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("alice")
|
||||
cookie := tserver.createSession("alice")
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("expected token")
|
||||
if cookie == "" {
|
||||
t.Fatal("expected auth cookie")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +621,7 @@ func TestCreateSessionMalformed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNoHeader(t *testing.T) {
|
||||
func TestAuthNoCookie(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
status, _ := tserver.getState("")
|
||||
@@ -626,11 +630,11 @@ func TestAuthNoHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthBadToken(t *testing.T) {
|
||||
func TestAuthBadCookie(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
status, _ := tserver.getState(
|
||||
"invalid-token-12345",
|
||||
"invalid-cookie-12345",
|
||||
)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", status)
|
||||
@@ -1827,90 +1831,6 @@ func assertFieldGTE(
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"nick": "reguser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %s",
|
||||
resp.StatusCode, respBody,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["token"] == nil || result["token"] == "" {
|
||||
t.Fatal("expected token in response")
|
||||
}
|
||||
|
||||
if result["nick"] != "reguser" {
|
||||
t.Fatalf(
|
||||
"expected reguser, got %v", result["nick"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicate(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"nick": "dupuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSONExpectStatus(
|
||||
t *testing.T,
|
||||
tserver *testServer,
|
||||
@@ -1945,36 +1865,102 @@ func postJSONExpectStatus(
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortPassword(t *testing.T) {
|
||||
func TestPassCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("passuser")
|
||||
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/register",
|
||||
map[string]string{
|
||||
"nick": "shortpw", "password": "short",
|
||||
// Drain initial messages.
|
||||
_, _ = tserver.pollMessages(token, 0)
|
||||
|
||||
// Set password via PASS command.
|
||||
status, result := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: "PASS",
|
||||
bodyKey: []string{"s3cure_pass"},
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
if result[statusKey] != "ok" {
|
||||
t.Fatalf(
|
||||
"expected ok, got %v", result[statusKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterInvalidNick(t *testing.T) {
|
||||
func TestPassCommandShortPassword(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("shortpw")
|
||||
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/register",
|
||||
map[string]string{
|
||||
"nick": "bad nick!",
|
||||
"password": "password123",
|
||||
// Drain initial messages.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try short password — should fail.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{
|
||||
commandKey: "PASS",
|
||||
bodyKey: []string{"short"},
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassCommandEmpty(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("emptypw")
|
||||
|
||||
// Drain initial messages.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
// Try empty password — should fail.
|
||||
status, _ := tserver.sendCommand(
|
||||
token,
|
||||
map[string]any{commandKey: "PASS"},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Register first.
|
||||
regBody, err := json.Marshal(map[string]string{
|
||||
// Create session and set password via PASS command.
|
||||
token := tserver.createSession("loginuser")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "PASS",
|
||||
bodyKey: []string{"password123"},
|
||||
})
|
||||
|
||||
// Login with nick + password.
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "loginuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1982,26 +1968,6 @@ func TestLoginValid(t *testing.T) {
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(regBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Login.
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "loginuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
@@ -2011,31 +1977,33 @@ func TestLoginValid(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp2.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %s",
|
||||
resp2.StatusCode, respBody,
|
||||
resp.StatusCode, respBody,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
// Extract auth cookie from login response.
|
||||
var loginCookie string
|
||||
|
||||
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == authCookieName {
|
||||
loginCookie = cookie.Value
|
||||
|
||||
if result["token"] == nil || result["token"] == "" {
|
||||
t.Fatal("expected token in response")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Verify token works.
|
||||
token, ok := result["token"].(string)
|
||||
if !ok {
|
||||
t.Fatal("token not a string")
|
||||
if loginCookie == "" {
|
||||
t.Fatal("expected auth cookie from login")
|
||||
}
|
||||
|
||||
status, state := tserver.getState(token)
|
||||
// Verify login cookie works for auth.
|
||||
status, state := tserver.getState(loginCookie)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
@@ -2051,49 +2019,22 @@ func TestLoginValid(t *testing.T) {
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
regBody, err := json.Marshal(map[string]string{
|
||||
"nick": "wrongpwuser", "password": "correctpass1",
|
||||
// Create session and set password via PASS command.
|
||||
token := tserver.createSession("wrongpwuser")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "PASS",
|
||||
bodyKey: []string{"correctpass1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(regBody),
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/login",
|
||||
map[string]string{
|
||||
"nick": "wrongpwuser",
|
||||
"password": "wrongpass12",
|
||||
},
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "wrongpwuser", "password": "wrongpass12",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf(
|
||||
"expected 401, got %d", resp2.StatusCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNonexistentUser(t *testing.T) {
|
||||
@@ -2109,13 +2050,74 @@ func TestLoginNonexistentUser(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestSessionCookie(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
body, err := json.Marshal(
|
||||
map[string]string{"nick": "cookietest"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url(apiSession),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify Set-Cookie header.
|
||||
var authCookie *http.Cookie
|
||||
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == authCookieName {
|
||||
authCookie = cookie
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if authCookie == nil {
|
||||
t.Fatal("expected neoirc_auth cookie")
|
||||
}
|
||||
|
||||
if !authCookie.HttpOnly {
|
||||
t.Fatal("cookie should be HttpOnly")
|
||||
}
|
||||
|
||||
if authCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatal("cookie should be SameSite=Strict")
|
||||
}
|
||||
|
||||
// Verify JSON body does NOT contain token.
|
||||
var result map[string]any
|
||||
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if _, hasToken := result["token"]; hasToken {
|
||||
t.Fatal("JSON body should not contain token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStillWorks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Verify anonymous session creation still works.
|
||||
token := tserver.createSession("anon_user")
|
||||
if token == "" {
|
||||
t.Fatal("expected token for anonymous session")
|
||||
t.Fatal("expected cookie for anonymous session")
|
||||
}
|
||||
|
||||
status, state := tserver.getState(token)
|
||||
|
||||
@@ -5,120 +5,11 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
// HandleRegister creates a new user with a password.
|
||||
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
request.Body = http.MaxBytesReader(
|
||||
writer, request.Body, hdlr.maxBodySize(),
|
||||
)
|
||||
|
||||
hdlr.handleRegister(writer, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleRegister(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
type registerRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var payload registerRequest
|
||||
|
||||
err := json.NewDecoder(request.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid request body",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||
|
||||
if !validNickRe.MatchString(payload.Nick) {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid nick format",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.Password) < minPasswordLength {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"password must be at least 8 characters",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.RegisterUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.handleRegisterError(
|
||||
writer, request, err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrSessions()
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
"token": token,
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleRegisterError(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
err error,
|
||||
) {
|
||||
if db.IsUniqueConstraintError(err) {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"nick already taken",
|
||||
http.StatusConflict,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.log.Error(
|
||||
"register user failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// HandleLogin authenticates a user with nick and password.
|
||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
||||
return func(
|
||||
@@ -195,9 +86,66 @@ func (hdlr *Handlers) handleLogin(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
hdlr.setAuthCookie(writer, request, token)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
"token": token,
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
// handlePass handles the IRC PASS command to set a
|
||||
// password on the authenticated session, enabling
|
||||
// multi-client login via POST /api/v1/login.
|
||||
func (hdlr *Handlers) handlePass(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdPass},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
password := lines[0]
|
||||
|
||||
if len(password) < minPasswordLength {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdPass},
|
||||
"Password must be at least 8 characters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := hdlr.params.Database.SetPassword(
|
||||
request.Context(), sessionID, password,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set password failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -126,18 +126,23 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||
// AllowCredentials is true so browsers include cookies in
|
||||
// cross-origin API requests.
|
||||
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowOriginFunc: func(
|
||||
_ *http.Request, _ string,
|
||||
) bool {
|
||||
return true
|
||||
},
|
||||
AllowedMethods: []string{
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Accept", "Authorization",
|
||||
"Content-Type", "X-CSRF-Token",
|
||||
"Accept", "Content-Type", "X-CSRF-Token",
|
||||
},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
AllowCredentials: true,
|
||||
MaxAge: corsMaxAge,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -75,10 +75,6 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Post(
|
||||
"/register",
|
||||
srv.handlers.HandleRegister(),
|
||||
)
|
||||
router.Post(
|
||||
"/login",
|
||||
srv.handlers.HandleLogin(),
|
||||
|
||||
Reference in New Issue
Block a user