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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 21:25:52 -04:00
parent dc43921bcc
commit 2f2dfa86df
4 changed files with 268 additions and 4 deletions
+1 -4
View File
@@ -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")
+82
View File
@@ -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,
},
})
}
+159
View File
@@ -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)
}
}
+26
View File
@@ -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"`
}