refactor: replace Bearer token auth with HttpOnly cookies
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:
user
2026-03-17 20:33:12 -07:00
parent bf4d63bc4d
commit cd9fd0c5c5
11 changed files with 625 additions and 711 deletions

View File

@@ -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

View File

@@ -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()