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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user