feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)

- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params
- token-bucket rate limiting (10/15min per IP+email) on login/register
- opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all
- CSRF double-submit cookie/header on authenticated mutations
- bootstrap admin from env with forced first-login password change
- requireAuth/requireRole middleware with disabled-account enforcement
- OIDC code flow with PKCE: lazy discovery, account linking only on
  verified email, auto-created developer accounts
- unit tests (RBAC matrix, CSRF, password, rate limiter) + integration
  suite covering the full auth matrix incl. an in-test fake IdP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:31:25 +02:00
parent f2c534f636
commit c1cc781279
25 changed files with 2318 additions and 24 deletions
+472
View File
@@ -0,0 +1,472 @@
//go:build integration
package httpx
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
"bountyboard/internal/testutil"
)
// newAuthStack builds a full server backed by a per-run Mongo database.
// extraEnv is applied before config.Load.
func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *Server, *store.Store, *config.Config) {
t.Helper()
db := testutil.MongoDB(t)
if err := store.EnsureIndexes(t.Context(), db); err != nil {
t.Fatal(err)
}
st := &store.Store{Client: db.Client(), DB: db}
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
for k, v := range extraEnv {
t.Setenv(k, v)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
logOut := io.Writer(io.Discard)
if os.Getenv("TEST_LOG") == "1" {
logOut = os.Stderr
}
srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, srv, st, cfg
}
// client returns an http client with a cookie jar that does not follow
// redirects (so OIDC hops can be inspected).
func newClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &http.Client{
Jar: jar,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
func postJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
if csrf != "" {
req.Header.Set(csrfHeader, csrf)
}
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func bodyJSON(t *testing.T, resp *http.Response, dst any) {
t.Helper()
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
t.Fatalf("decode response: %v", err)
}
}
func csrfFrom(t *testing.T, c *http.Client, baseURL string) string {
t.Helper()
u, _ := url.Parse(baseURL)
for _, ck := range c.Jar.Cookies(u) {
if ck.Name == csrfCookie {
return ck.Value
}
}
return ""
}
func errCode(t *testing.T, resp *http.Response) string {
t.Helper()
var env struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
bodyJSON(t, resp, &env)
return env.Error.Code
}
func TestRegisterLoginLogoutFlow(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
c := newClient(t)
// invalid inputs
for _, tc := range []struct {
body map[string]string
want string
}{
{map[string]string{"email": "not-an-email", "name": "X", "password": "longenough"}, "invalid_email"},
{map[string]string{"email": "a@b.co", "name": "", "password": "longenough"}, "invalid_name"},
{map[string]string{"email": "a@b.co", "name": "X", "password": "short"}, "weak_password"},
} {
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", tc.body, "")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("register %v: code %d", tc.body, resp.StatusCode)
}
if got := errCode(t, resp); got != tc.want {
t.Errorf("error code %q, want %q", got, tc.want)
}
}
// successful registration creates a developer and a session
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "Dev@Example.com", "name": "Dev One", "password": "password123"}, "")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register: %d", resp.StatusCode)
}
var reg struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &reg)
if reg.User.Email != "dev@example.com" {
t.Errorf("email not normalized: %q", reg.User.Email)
}
if !reg.User.Roles.Developer || reg.User.Roles.Admin || reg.User.Roles.Consultant {
t.Errorf("self-registration roles wrong: %+v", reg.User.Roles)
}
// session works
meResp, err := c.Get(ts.URL + "/api/v1/auth/me")
if err != nil || meResp.StatusCode != http.StatusOK {
t.Fatalf("me after register: %v %d", err, meResp.StatusCode)
}
meResp.Body.Close()
// duplicate email
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/register",
map[string]string{"email": "dev@example.com", "name": "Imposter", "password": "password123"}, "")
if resp.StatusCode != http.StatusConflict {
t.Fatalf("duplicate register: %d", resp.StatusCode)
}
resp.Body.Close()
// logout requires CSRF
resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, "")
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("logout without csrf: %d", resp.StatusCode)
}
resp.Body.Close()
csrf := csrfFrom(t, c, ts.URL)
if csrf == "" {
t.Fatal("csrf cookie not set")
}
resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, csrf)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: %d", resp.StatusCode)
}
resp.Body.Close()
meResp, err = c.Get(ts.URL + "/api/v1/auth/me")
if err != nil || meResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("me after logout: %v %d", err, meResp.StatusCode)
}
meResp.Body.Close()
// fresh login
c2 := newClient(t)
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
map[string]string{"email": "dev@example.com", "password": "password123"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d", resp.StatusCode)
}
resp.Body.Close()
// wrong password and unknown email are indistinguishable 401s
for _, body := range []map[string]string{
{"email": "dev@example.com", "password": "wrong-password"},
{"email": "ghost@example.com", "password": "password123"},
} {
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", body, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("login %v: %d", body, resp.StatusCode)
}
if got := errCode(t, resp); got != "invalid_credentials" {
t.Errorf("error code %q", got)
}
}
}
func TestLoginRateLimit(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
for i := 0; i < 10; i++ {
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "victim@example.com", "password": "guess"}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("attempt %d: %d", i+1, resp.StatusCode)
}
resp.Body.Close()
}
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "victim@example.com", "password": "guess"}, "")
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("11th attempt: %d, want 429", resp.StatusCode)
}
resp.Body.Close()
// other accounts from the same IP are unaffected (key includes email)
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "other@example.com", "password": "guess"}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("different email after limit: %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestDisabledAccount(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c := newClient(t)
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "d@example.com", "name": "D", "password": "password123"}, "")
var reg struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &reg)
// disable directly in the store
if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
map[string]any{"_id": reg.User.ID}, map[string]any{"$set": map[string]any{"disabled": true}}); err != nil {
t.Fatal(err)
}
// existing session is revoked on next use
meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
if meResp.StatusCode != http.StatusForbidden {
t.Fatalf("me as disabled: %d", meResp.StatusCode)
}
meResp.Body.Close()
// fresh login rejected
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "d@example.com", "password": "password123"}, "")
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("login as disabled: %d", resp.StatusCode)
}
if got := errCode(t, resp); got != "account_disabled" {
t.Errorf("error code %q", got)
}
}
func TestBootstrapAdminAndForcedPasswordChange(t *testing.T) {
ts, srv, st, cfg := newAuthStack(t, map[string]string{
"ADMIN_EMAIL": "root@example.com",
"ADMIN_INITIAL_PASSWORD": "initial-password",
})
if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
t.Fatal(err)
}
// idempotent
if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
t.Fatal(err)
}
if n, _ := st.CountUsers(t.Context()); n != 1 {
t.Fatalf("user count = %d, want 1", n)
}
c := newClient(t)
resp := postJSON(t, c, ts.URL+"/api/v1/auth/login",
map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin login: %d", resp.StatusCode)
}
var login struct {
User domain.User `json:"user"`
MustChangePassword bool `json:"mustChangePassword"`
}
bodyJSON(t, resp, &login)
if !login.User.Roles.Admin || !login.MustChangePassword {
t.Fatalf("bootstrap admin login: %+v must=%v", login.User.Roles, login.MustChangePassword)
}
// while mustChange is set, non-allowlisted endpoints are blocked
csrf := csrfFrom(t, c, ts.URL)
probe := httptest.NewServer(srv.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer probe.Close()
req, _ := http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
u, _ := url.Parse(ts.URL)
for _, ck := range c.Jar.Cookies(u) {
req.AddCookie(ck)
}
pResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if pResp.StatusCode != http.StatusForbidden {
t.Fatalf("protected endpoint under mustChange: %d, want 403", pResp.StatusCode)
}
pResp.Body.Close()
// wrong current password
resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
map[string]string{"currentPassword": "nope", "newPassword": "fresh-password-1"}, csrf)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("change with wrong current: %d", resp.StatusCode)
}
resp.Body.Close()
// correct change clears the flag
resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
map[string]string{"currentPassword": "initial-password", "newPassword": "fresh-password-1"}, csrf)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change password: %d", resp.StatusCode)
}
resp.Body.Close()
req, _ = http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
for _, ck := range c.Jar.Cookies(u) {
req.AddCookie(ck)
}
pResp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if pResp.StatusCode != http.StatusOK {
t.Fatalf("protected endpoint after change: %d", pResp.StatusCode)
}
pResp.Body.Close()
// old password dead, new one works
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("old password still works: %d", resp.StatusCode)
}
resp.Body.Close()
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "root@example.com", "password": "fresh-password-1"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("new password rejected: %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestPasswordChangeRevokesOtherSessions(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
c1, c2 := newClient(t), newClient(t)
resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "u@example.com", "name": "U", "password": "password123"}, "")
resp.Body.Close()
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
map[string]string{"email": "u@example.com", "password": "password123"}, "")
resp.Body.Close()
resp = postJSON(t, c1, ts.URL+"/api/v1/auth/change-password",
map[string]string{"currentPassword": "password123", "newPassword": "password456"}, csrfFrom(t, c1, ts.URL))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change: %d", resp.StatusCode)
}
resp.Body.Close()
// c1 (the changer) still works, c2 is revoked
r1, _ := c1.Get(ts.URL + "/api/v1/auth/me")
if r1.StatusCode != http.StatusOK {
t.Errorf("changer session: %d", r1.StatusCode)
}
r1.Body.Close()
r2, _ := c2.Get(ts.URL + "/api/v1/auth/me")
if r2.StatusCode != http.StatusUnauthorized {
t.Errorf("other session after password change: %d, want 401", r2.StatusCode)
}
r2.Body.Close()
}
func TestExpiredSessionRejected(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c := newClient(t)
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "e@example.com", "name": "E", "password": "password123"}, "")
resp.Body.Close()
// force-expire every session for the user
if _, err := st.DB.Collection("sessions").UpdateMany(t.Context(),
map[string]any{}, map[string]any{"$set": map[string]any{"expiresAt": time.Now().Add(-time.Minute)}}); err != nil {
t.Fatal(err)
}
meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
if meResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expired session: %d", meResp.StatusCode)
}
meResp.Body.Close()
}
func TestLogoutAll(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
c1, c2 := newClient(t), newClient(t)
resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "la@example.com", "name": "LA", "password": "password123"}, "")
resp.Body.Close()
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
map[string]string{"email": "la@example.com", "password": "password123"}, "")
resp.Body.Close()
resp = postJSON(t, c1, ts.URL+"/api/v1/auth/logout-all", map[string]string{}, csrfFrom(t, c1, ts.URL))
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout-all: %d", resp.StatusCode)
}
var out struct {
Revoked int `json:"revoked"`
}
bodyJSON(t, resp, &out)
if out.Revoked != 2 {
t.Errorf("revoked = %d, want 2", out.Revoked)
}
for i, c := range []*http.Client{c1, c2} {
r, _ := c.Get(ts.URL + "/api/v1/auth/me")
if r.StatusCode != http.StatusUnauthorized {
t.Errorf("client %d after logout-all: %d", i+1, r.StatusCode)
}
r.Body.Close()
}
}
func TestOIDCOnlyAccountCannotPasswordLogin(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
if err := st.CreateUser(t.Context(), &domain.User{
ID: "01JOIDCONLY0000000000000US", Email: "sso@example.com", Name: "SSO",
Roles: domain.Roles{Developer: true},
Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: "https://idp", Subject: "x"}},
}); err != nil {
t.Fatal(err)
}
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "sso@example.com", "password": "whatever123"}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("oidc-only password login: %d", resp.StatusCode)
}
resp.Body.Close()
}