package api import ( "encoding/json" "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "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, apierror.BadRequest("bad_request", "invalid JSON body")) return } if req.Username == "" || req.Password == "" { writeErr(w, apierror.BadRequest("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, apierror.Unauthorized("invalid_credentials", "invalid username or password")) return } h.logger.Error("api: user lookup failed", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } if !auth.VerifyPassword(user.PasswordHash, req.Password) { writeErr(w, apierror.Unauthorized("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, apierror.InternalMsg("mint failed", err)) 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, apierror.InternalMsg("insert failed", err)) 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, }, }) }