From 2f2dfa86df423daa51da505e0bf131957caec5de Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 21:25:52 -0400 Subject: [PATCH] feat(api): POST /api/auth/login Verifies bcrypt password hash, mints a session token, stores its sha256 in the sessions table, and returns the token in both the response body (for Flutter) and an httpOnly/SameSite=Strict cookie (for the web SPA). Co-Authored-By: Claude Opus 4.7 --- internal/api/api.go | 5 +- internal/api/auth.go | 82 ++++++++++++++++++++ internal/api/auth_test.go | 159 ++++++++++++++++++++++++++++++++++++++ internal/api/types.go | 26 +++++++ 4 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 internal/api/auth.go create mode 100644 internal/api/auth_test.go create mode 100644 internal/api/types.go diff --git a/internal/api/api.go b/internal/api/api.go index ab77d087..02435cfe 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,10 +35,7 @@ type handlers struct { } // Stub handlers so Mount() compiles. Real implementations in later tasks -// replace these in place (Task 6 login, Task 7 logout, Task 8 me). -func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "login not wired") -} +// replace these in place (Task 7 logout, Task 8 me). func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired") diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 00000000..0e1c6b2f --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,82 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire +// server-side yet (future work); the cookie still caps browser-side lifetime +// so an abandoned laptop doesn't stay logged in forever. +const sessionCookieMaxAge = 30 * 24 * time.Hour + +func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") + return + } + if req.Username == "" || req.Password == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "username and password required") + return + } + + q := dbq.New(h.pool) + user, err := q.GetUserByUsername(r.Context(), req.Username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") + return + } + h.logger.Error("api: user lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + if !auth.VerifyPassword(user.PasswordHash, req.Password) { + writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") + return + } + + token, err := auth.MintSessionToken() + if err != nil { + h.logger.Error("api: mint session token failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "mint failed") + return + } + if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{ + UserID: user.ID, + TokenHash: auth.HashSessionToken(token), + UserAgent: r.UserAgent(), + }); err != nil { + h.logger.Error("api: insert session failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure + SameSite: http.SameSiteStrictMode, + MaxAge: int(sessionCookieMaxAge.Seconds()), + }) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(LoginResponse{ + Token: token, + User: UserView{ + ID: user.ID, + Username: user.Username, + IsAdmin: user.IsAdmin, + }, + }) +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 00000000..4cd8eb1a --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL. +// Skips in -short mode or when the env var is missing, matching the pattern +// used elsewhere (scanner_test.go, etc.). +func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { + t.Helper() + if testing.Short() { + t.Skip("skipping api integration in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := db.Migrate(dsn, logger); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), + "TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + return &handlers{pool: pool, logger: logger}, pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatalf("bcrypt: %v", err) + } + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: username, + PasswordHash: string(hash), + ApiToken: "test-api-token-" + username, + IsAdmin: isAdmin, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + return u +} + +func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) { + h, pool := testHandlers(t) + seedUser(t, pool, "alice", "hunter2", false) + + body := strings.NewReader(`{"username":"alice","password":"hunter2"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + + var resp struct { + Token string `json:"token"` + User struct { + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + } `json:"user"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody=%s", err, w.Body.String()) + } + if resp.Token == "" { + t.Error("token empty") + } + if resp.User.Username != "alice" || resp.User.IsAdmin { + t.Errorf("user = %+v, want alice/non-admin", resp.User) + } + + var cookieFound bool + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName { + cookieFound = true + if !c.HttpOnly { + t.Error("session cookie missing HttpOnly") + } + if c.SameSite != http.SameSiteStrictMode { + t.Errorf("SameSite = %v, want Strict", c.SameSite) + } + if c.Value != resp.Token { + t.Error("cookie value does not match response token") + } + } + } + if !cookieFound { + t.Error("session cookie not set") + } +} + +func TestHandleLogin_WrongPasswordReturns401(t *testing.T) { + h, pool := testHandlers(t) + seedUser(t, pool, "alice", "hunter2", false) + + body := strings.NewReader(`{"username":"alice","password":"wrong"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleLogin_UnknownUserReturns401(t *testing.T) { + h, _ := testHandlers(t) + body := strings.NewReader(`{"username":"ghost","password":"whatever"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", + bytes.NewReader([]byte("not-json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 00000000..565a5257 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,26 @@ +package api + +import "github.com/jackc/pgx/v5/pgtype" + +// LoginRequest is the POST /api/auth/login body. Kept boring on purpose — +// web and Flutter both send the same payload. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse carries the opaque session token AND the authenticated user +// so the SPA can hydrate its auth store in one round-trip. The token is also +// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer. +type LoginResponse struct { + Token string `json:"token"` + User UserView `json:"user"` +} + +// UserView is the /api/* view of a user. Narrower than dbq.User — no hash, +// no api_token, no subsonic_password. +type UserView struct { + ID pgtype.UUID `json:"id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` +}