Files
minstrel/internal/api/auth.go
T
bvandeusen 1e5c8b5ab7 fix(api): trim whitespace from bearer token in logout
sessionTokenFromHTTP now matches extractBearerToken's trimming
behavior. Without this, a bearer header with trailing whitespace
(e.g. a buggy client or proxy) authenticates via RequireUser but
hashes the padded value on logout, silently no-oping and leaving
the server-side session alive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:32:12 -04:00

124 lines
3.9 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"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) handleLogout(w http.ResponseWriter, r *http.Request) {
// The session token can be on the cookie OR bearer header — RequireUser
// accepted either. Re-resolve it here so we can delete the row.
token := sessionTokenFromHTTP(r)
if token != "" {
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
h.logger.Warn("api: delete session failed", "err", err)
// Continue — logout is best-effort; the client still gets the
// cookie cleared.
}
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
// because that one is unexported. Cheap to repeat here; keeping the auth
// package's internal helper package-private is worth more than DRY. Must
// match that helper's trimming behavior exactly — otherwise a bearer with
// trailing whitespace authenticates via RequireUser (which trims) but logout
// hashes the padded value and silently no-ops, leaving the session alive.
func sessionTokenFromHTTP(r *http.Request) string {
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
return strings.TrimSpace(h[len(prefix):])
}
return ""
}
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,
},
})
}