Two CI failures from 381e9ced, both mine.
**Go (vet, which cascaded into the integration job).** Widening
auth.ClientIP to take a hop count, I updated the middleware that TOUCHES a
session but missed the two places that CREATE one — handleLogin and
handleRegister. So `created_ip`, the frozen origin address that the whole
"address changed" comparison rests on, was the one value still being
computed the old way. Both now read h.netSettings.Hops(), which is nil-safe
so test handlers constructed without the service still work.
Worth noting the shape of this miss: I checked call sites by searching for
the middleware's own usage and stopped there, rather than for every caller of
the function whose signature I changed. vet found it in seconds; a grep for
`auth.ClientIP(` would have too.
**Web (vitest).** Three tests waited on `findByText('198.51.100.7')`, which
matches TWO elements in the fixture — the detected client address and the
forwarded chain, identical strings for a single-proxy setup — and findByText
throws on multiple matches. Now they wait on the unique "Your address right
now" label and assert the address with getAllByText where duplication is
legitimate. The duplication is correct behaviour, so the test moved rather
than the component.
130 lines
4.2 KiB
Go
130 lines
4.2 KiB
Go
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(),
|
|
// Origin address, frozen at issue time. Compared against last_ip in
|
|
// the active-sessions surface: a session that was born somewhere the
|
|
// user recognises but is being used from somewhere they don't is the
|
|
// case this whole surface exists to surface.
|
|
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
|
}); 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,
|
|
},
|
|
})
|
|
}
|