Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings

This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

Features (built earlier, previously uncommitted):
- Local username/password login for single-tenant deployments with no
  SSO configured (api/localauth, alerting/internal/sessioncheck,
  sentryctl users, web/src/routes/login, metadata migrations 0040/0041).
- Remotely-editable additional log file paths for agents, on top of
  their existing primary source (api/agents, agent/sentry-agent
  extra-file-path diffing, web agent config UI).
- IPv4/IPv6 addresses reported alongside other host system metrics.

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+70
View File
@@ -0,0 +1,70 @@
package localauth
import (
"context"
"errors"
"net/http"
"github.com/sentry/sentry/api/authz"
)
// sessionStore is the narrow interface Authorizer depends on -- *Store
// is the production implementation; tests use a fake.
type sessionStore interface {
GetSession(ctx context.Context, tokenHash string) (*Session, error)
}
// sessionCookieName is also read directly by handler.go (Set-Cookie on
// login/logout) and is the one piece of this package's shape web/
// needs to know about implicitly (via credentials: 'include', not by
// name -- the browser handles the cookie, JS never reads it since it's
// HttpOnly).
const sessionCookieName = "sentry_local_session"
// Authorizer implements api/authz.Authorizer against local_sessions --
// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer
// variable is what turns every existing RequireRole-wrapped route in
// dashboards/agents/queryapi/aiapi from a no-op into real enforcement,
// with no changes needed to any of those handler files (see this
// package's doc comment).
type Authorizer struct {
store sessionStore
}
func NewAuthorizer(store sessionStore) *Authorizer {
return &Authorizer{store: store}
}
var errNoCredential = errors.New("localauth: no session credential presented")
// Authorize checks Authorization: Bearer first (sentryctl and other
// non-browser callers), then the session cookie (the web UI) -- same
// precedence authz.HTTPAuthorizer's caller-side forwarding implies,
// and the same reason POST /auth/login's response body returns the raw
// token alongside setting the cookie (see handler.go): one opaque
// value works both ways.
func (a *Authorizer) Authorize(r *http.Request) (authz.Identity, error) {
raw, err := credentialFromRequest(r)
if err != nil {
return authz.Identity{}, err
}
sess, err := a.store.GetSession(r.Context(), hashToken(raw))
if err != nil {
return authz.Identity{}, err
}
return authz.Identity{TenantID: sess.TenantID, UserID: sess.UserID, Role: sess.Role}, nil
}
func credentialFromRequest(r *http.Request) (string, error) {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):], nil
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value, nil
}
return "", errNoCredential
}
+127
View File
@@ -0,0 +1,127 @@
package localauth
import (
"context"
"strconv"
"time"
"github.com/sentry/sentry/api/authz"
)
// fakeStore implements both store (handler.go) and sessionStore
// (authorizer.go) -- a real *Store satisfies both too, this is just the
// in-memory test double, same "fake enforces the same invariants the
// real pgx-backed Store does" posture dashboards/handler_test.go's
// fakeStore documents.
type fakeStore struct {
users map[string]*User // by id
hashes map[string]string
byUsername map[string]string // username -> id
sessions map[string]Session
nextID int
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]*User{},
hashes: map[string]string{},
byUsername: map[string]string{},
sessions: map[string]Session{},
}
}
func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, role authz.Role) (*User, error) {
if f.createErr != nil {
return nil, f.createErr
}
if _, ok := f.byUsername[username]; ok {
return nil, ErrUsernameTaken
}
f.nextID++
id := "user-" + strconv.Itoa(f.nextID)
u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()}
f.users[id] = u
f.hashes[id] = passwordHash
f.byUsername[username] = id
return u, nil
}
func (f *fakeStore) ListUsers(_ context.Context) ([]User, error) {
var out []User
for _, u := range f.users {
out = append(out, *u)
}
return out, nil
}
func (f *fakeStore) GetUserForLogin(_ context.Context, username string) (*User, string, error) {
id, ok := f.byUsername[username]
if !ok {
return nil, "", ErrNotFound
}
return f.users[id], f.hashes[id], nil
}
func (f *fakeStore) GetUserByID(_ context.Context, id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (f *fakeStore) DeleteUser(_ context.Context, id string) error {
u, ok := f.users[id]
if !ok {
return ErrNotFound
}
delete(f.users, id)
delete(f.hashes, id)
delete(f.byUsername, u.Username)
for hash, sess := range f.sessions {
if sess.UserID == id {
delete(f.sessions, hash)
}
}
return nil
}
func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) error {
if _, ok := f.users[userID]; !ok {
return ErrNotFound
}
f.hashes[userID] = hash
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
func (f *fakeStore) CreateSession(_ context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
f.sessions[hash] = Session{UserID: userID, TenantID: tenantID, Role: role, ExpiresAt: time.Now().Add(ttl)}
return raw, nil
}
func (f *fakeStore) GetSession(_ context.Context, tokenHash string) (*Session, error) {
sess, ok := f.sessions[tokenHash]
if !ok || sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
func (f *fakeStore) DeleteSessionByHash(_ context.Context, tokenHash string) error {
delete(f.sessions, tokenHash)
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package localauth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/sentry/sentry/api/authz"
)
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// dashboards.store/agents.store.
type store interface {
CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error)
ListUsers(ctx context.Context) ([]User, error)
GetUserForLogin(ctx context.Context, username string) (*User, string, error)
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
// CookieConfig is the deployment-specific half of how the session
// cookie is set -- everything else about it (name, HttpOnly, SameSite)
// is fixed by this package, not configurable per deployment.
type CookieConfig struct {
// Domain is typically empty for local dev (host-only cookie, works
// fine when web/api are both localhost:<port>) and something like
// ".sentry.example.com" in production, so the same cookie is sent to
// api.sentry.example.com and alerting.sentry.example.com too -- see
// /docs (deployment runbook) for the subdomain scheme this assumes.
Domain string
// Secure defaults to true (the cookie is never sent over plain
// HTTP) -- deliberately opt-out, not opt-in, since the real
// deployment this feature exists for is always behind HTTPS. Only
// worth setting false to test the login flow locally over plain
// http://localhost.
Secure bool
}
// loginRateLimitMax/Window bound how many login attempts one client IP
// may make -- see loginLimiter's doc comment for why this is per-IP,
// in-memory, and counts both successful and failed attempts. 10 per 5
// minutes is generous enough that a real user mistyping a password a
// few times never notices, while still bounding an online brute-force
// attempt to a few attempts per minute.
const (
loginRateLimitMax = 10
loginRateLimitWindow = 5 * time.Minute
)
type Handler struct {
logger *slog.Logger
store store
authorizer authz.Authorizer
sessionTTL time.Duration
cookies CookieConfig
loginLimits *loginLimiter
}
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, sessionTTL time.Duration, cookies CookieConfig) *Handler {
return &Handler{
logger: logger,
store: store,
authorizer: authorizer,
sessionTTL: sessionTTL,
cookies: cookies,
loginLimits: newLoginLimiter(loginRateLimitMax, loginRateLimitWindow),
}
}
// RegisterRoutes is only ever called when local auth is enabled (see
// cmd/api/main.go) -- a deployment that doesn't enable it simply never
// registers these routes at all, so GET /auth/session (etc.) 404s
// rather than needing its own "is this feature even on" response
// shape. Login/logout/session are deliberately NOT RequireRole-wrapped
// with anything above RoleViewer's floor: login is how you become
// authenticated in the first place, logout/session must work for any
// already-authenticated user regardless of role.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", h.handleLogin)
mux.HandleFunc("POST /auth/logout", h.handleLogout)
mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession))
mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleListUsers))
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type sessionResponse struct {
// Token duplicates what the Set-Cookie header already carries,
// specifically for non-browser callers with no cookie jar --
// sentryctl captures this into SENTRYCTL_TOKEN and sends it back as
// Authorization: Bearer (see authorizer.go's credentialFromRequest,
// which accepts either). The web UI ignores this field entirely and
// relies on the cookie.
Token string `json:"token"`
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Username string `json:"username"`
Role string `json:"role"`
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if !h.loginLimits.allow(clientIP(r)) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "username and password are required")
return
}
user, hash, err := h.store.GetUserForLogin(r.Context(), req.Username)
if err != nil {
if errors.Is(err, ErrNotFound) {
// Run a dummy bcrypt comparison even though there's no real
// hash to check -- otherwise this branch returns immediately
// while a known-username branch always pays bcrypt's cost
// below, and that timing gap lets a patient caller enumerate
// valid usernames by response latency alone even though the
// error message text is identical either way.
ComparePassword(dummyPasswordHash, req.Password)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
h.logger.Error("looking up user for login", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !ComparePassword(hash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
raw, err := h.store.CreateSession(r.Context(), user.ID, defaultTenantID, user.Role, h.sessionTTL)
if err != nil {
h.logger.Error("creating session", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
h.setCookie(w, raw, h.sessionTTL)
writeJSON(w, http.StatusOK, sessionResponse{
Token: raw, UserID: user.ID, TenantID: defaultTenantID,
Username: user.Username, Role: string(user.Role),
})
}
// handleLogout always responds 204, whether or not a valid session was
// presented -- "log me out" is idempotent from the caller's point of
// view either way.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
if raw, err := credentialFromRequest(r); err == nil {
if err := h.store.DeleteSessionByHash(r.Context(), hashToken(raw)); err != nil {
h.logger.Error("deleting session", "error", err)
}
}
h.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// handleGetSession is what web's route guard (+layout.ts) polls on
// every navigation -- RequireRole(RoleViewer) above already turns "no
// valid session" into a 401 before this ever runs, so by the time
// we're here the identity is real.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
identity, _ := authz.IdentityFromContext(r.Context())
user, err := h.store.GetUserByID(r.Context(), identity.UserID)
if err != nil {
h.writeStoreErr(w, err, "fetching session user")
return
}
writeJSON(w, http.StatusOK, sessionResponse{
UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role),
})
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context())
if err != nil {
h.logger.Error("listing users", "error", err)
writeError(w, http.StatusInternalServerError, "listing users failed")
return
}
out := make([]userResponse, len(users))
for i, u := range users {
out[i] = userResponse{ID: u.ID, Username: u.Username, Role: string(u.Role), CreatedAt: u.CreatedAt}
}
writeJSON(w, http.StatusOK, out)
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
func validRole(r authz.Role) bool {
switch r {
case authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner:
return true
default:
return false
}
}
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username must not be empty")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := authz.Role(req.Role)
if role == "" {
role = authz.RoleEditor
}
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
user, err := h.store.CreateUser(r.Context(), req.Username, hash, role)
if err != nil {
if errors.Is(err, ErrUsernameTaken) {
writeError(w, http.StatusConflict, "username already taken")
return
}
h.logger.Error("creating user", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
writeJSON(w, http.StatusCreated, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
// handleDeleteUser deliberately does not stop an admin from deleting
// their own account -- this package has no separate "you can't remove
// the last admin" guard; a single-operator prototype deployment is
// expected to know what it's doing here, same trust level the rest of
// this codebase's admin-only endpoints assume.
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteUser(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting user")
return
}
w.WriteHeader(http.StatusNoContent)
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,
// never stored, never recoverable" posture as -seed-admin's initial
// password (see cmd/api/main.go's runSeedAdmin).
Password string `json:"password,omitempty"`
}
type resetPasswordResponse struct {
// Password is only set when the request didn't supply one --
// omitempty so an admin-supplied reset doesn't echo it back.
Password string `json:"password,omitempty"`
}
func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
// An empty body is valid here (generate a random password) --
// decodeJSON's json.Decode on an empty io.Reader would error, so
// this endpoint reads the body directly instead of reusing
// decodeJSON, tolerating "no body at all" as "use defaults."
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
}
plaintext := req.Password
generated := false
if plaintext == "" {
raw, _, err := newOpaqueToken()
if err != nil {
h.logger.Error("generating random password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
plaintext = raw
generated = true
} else if len(plaintext) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := HashPassword(plaintext)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
if err := h.store.SetPasswordHash(r.Context(), r.PathValue("id"), hash); err != nil {
h.writeStoreErr(w, err, "resetting password")
return
}
resp := resetPasswordResponse{}
if generated {
resp.Password = plaintext
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Domain: h.cookies.Domain,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Domain: h.cookies.Domain,
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
+261
View File
@@ -0,0 +1,261 @@
package localauth
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) {
t.Helper()
authorizer := NewAuthorizer(fs)
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, authorizer, time.Hour, CookieConfig{})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return h, mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func mustCreateUser(t *testing.T, fs *fakeStore, username, password string, role authz.Role) *User {
t.Helper()
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hashing password: %v", err)
}
u, err := fs.CreateUser(t.Context(), username, hash, role)
if err != nil {
t.Fatalf("creating user: %v", err)
}
return u
}
func sessionCookieFrom(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func TestLoginSuccess(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
cookie := sessionCookieFrom(rec)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected a session cookie to be set, got none")
}
if !cookie.HttpOnly {
t.Errorf("session cookie must be HttpOnly")
}
var resp sessionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Token == "" {
t.Errorf("expected the response body to also carry the raw token for non-browser callers")
}
if resp.Role != "editor" {
t.Errorf("role = %q, want editor", resp.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestLoginUnknownUserSameErrorAsWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
unknown := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"whatever"}`, nil)
wrongPass := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if unknown.Code != http.StatusUnauthorized || wrongPass.Code != http.StatusUnauthorized {
t.Fatalf("both must be 401, got unknown=%d wrongPass=%d", unknown.Code, wrongPass.Code)
}
if unknown.Body.String() != wrongPass.Body.String() {
t.Errorf("responses must be identical (no username enumeration): unknown=%q wrongPass=%q", unknown.Body.String(), wrongPass.Body.String())
}
}
func TestSessionRequiresAuth(t *testing.T) {
fs := newFakeStore()
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodGet, "/auth/session", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 with no session cookie", rec.Code)
}
}
func TestLoginThenSessionRoundTrip(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", sess.Code, sess.Body.String())
}
var resp sessionResponse
if err := json.Unmarshal(sess.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Username != "alice" {
t.Errorf("username = %q, want alice", resp.Username)
}
}
func TestLogoutInvalidatesSession(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
logout := doRequest(t, mux, http.MethodPost, "/auth/logout", "", cookie)
if logout.Code != http.StatusNoContent {
t.Fatalf("logout status = %d, want 204", logout.Code)
}
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusUnauthorized {
t.Fatalf("status after logout = %d, want 401 (session must be revoked)", sess.Code)
}
}
func TestNonOwnerCannotManageUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner listing users", rec.Code)
}
}
func TestOwnerCanCreateAndDeleteUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
create := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"bobspassword","role":"viewer"}`, cookie)
if create.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201; body=%s", create.Code, create.Body.String())
}
var created userResponse
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding response: %v", err)
}
if created.Role != "viewer" {
t.Errorf("role = %q, want viewer", created.Role)
}
if created.CreatedAt.IsZero() {
t.Errorf("created_at was not populated in the create response")
}
list := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
var users []userResponse
if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(users) != 2 {
t.Fatalf("len(users) = %d, want 2 (admin + bob)", len(users))
}
del := doRequest(t, mux, http.MethodDelete, "/auth/users/"+created.ID, "", cookie)
if del.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204", del.Code)
}
}
func TestCreateUserRejectsShortPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"short"}`, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a too-short password", rec.Code)
}
}
func TestResetPasswordRevokesExistingSessions(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", "", adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password == "" {
t.Fatalf("expected a generated password in the response when none was supplied")
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
+29
View File
@@ -0,0 +1,29 @@
package localauth
import "golang.org/x/crypto/bcrypt"
// dummyPasswordHash is a precomputed bcrypt hash of an arbitrary,
// never-used-as-a-real-password string -- handleLogin runs
// ComparePassword against this on the "no such user" path purely to pay
// the same bcrypt cost the "wrong password" path already pays, closing
// a response-time side channel that would otherwise let a caller
// distinguish the two despite their identical error message. There is
// no real password behind this hash; it exists only to burn comparable
// CPU time.
const dummyPasswordHash = "$2a$10$fH9R3O6ViQ6c7bq0N7yyBO1JP2TOw/bZEopMyZKBYrBjgYBZO9rCa"
// HashPassword and ComparePassword are the only two places this package
// touches a raw password -- everywhere else, a user is identified by an
// already-issued session token (see token.go), never by re-checking a
// password on every request.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func ComparePassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+91
View File
@@ -0,0 +1,91 @@
package localauth
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// loginLimiter is a simple in-memory sliding-window rate limiter for
// POST /auth/login, keyed by client IP -- closes a real gap the
// security audit found: nothing in the application, nginx, or the host
// (no fail2ban either) throttled repeated login attempts, making
// sustained online brute-forcing of a weaker, human-chosen password
// possible. (The auto-generated admin password is high-entropy, but
// every user created afterward only needs 8 characters with no
// complexity check -- see handleCreateUser.)
//
// Per-IP rather than per-username: a per-username-only limiter is
// itself a denial-of-service vector (deliberately fail a real
// username's login repeatedly, from anywhere, to lock them out), and
// wouldn't bound an attacker guessing across many usernames from one
// source. Both successful and failed attempts count against the
// window, not just failures -- simpler, and it means a low-and-slow
// guesser can't reset their budget by occasionally succeeding against
// an unrelated account.
//
// Deliberately in-memory, not Postgres-backed: login rate limiting is
// inherently best-effort per-process state (a restart clearing it is
// fine, unlike a session or password), and adding a database
// round-trip to every login attempt is the wrong tradeoff for a check
// whose only job is bounding attempt *rate*. Memory for IPs that stop
// attempting entirely is only reclaimed the next time that exact key is
// looked up -- a deliberate, bounded-in-practice simplicity tradeoff
// (real attacker/user IP cardinality against one deployment is small
// relative to a process's lifetime between deploys), not an oversight.
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
max int
window time.Duration
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{attempts: map[string][]time.Time{}, max: max, window: window}
}
// allow reports whether key may attempt another login right now, and
// records this attempt if so (a denied call does not itself count as a
// new attempt -- it just reports the existing window is full).
func (l *loginLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
var kept []time.Time
for _, t := range l.attempts[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.max {
l.attempts[key] = kept
return false
}
l.attempts[key] = append(kept, now)
return true
}
// clientIP extracts the caller's address for rate-limiting purposes.
// Trusts the first hop of X-Forwarded-For when present -- correct for
// this deployment's actual topology (always behind nginx, which sets
// it), but note this is spoofable by any caller that reaches the
// application directly rather than through the trusted proxy; a
// deployment that exposes api's port directly to untrusted clients
// should not rely on this header. Falls back to r.RemoteAddr, which is
// always accurate for whoever the TCP connection is actually with.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+84
View File
@@ -0,0 +1,84 @@
package localauth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func TestLoginLimiterAllowsUpToMax(t *testing.T) {
l := newLoginLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("attempt %d: want allowed", i+1)
}
}
if l.allow("1.2.3.4") {
t.Fatal("4th attempt within the window: want denied")
}
}
func TestLoginLimiterIsPerKey(t *testing.T) {
l := newLoginLimiter(1, time.Minute)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt from 1.2.3.4: want allowed")
}
if !l.allow("5.6.7.8") {
t.Fatal("a different IP must have its own budget")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt from 1.2.3.4: want denied")
}
}
func TestLoginLimiterResetsAfterWindow(t *testing.T) {
l := newLoginLimiter(1, 10*time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt: want allowed")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt within the window: want denied")
}
time.Sleep(20 * time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("attempt after the window elapsed: want allowed")
}
}
func TestClientIPPrefersForwardedFor(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP() = %q, want %q", got, "203.0.113.9")
}
}
func TestClientIPFallsBackToRemoteAddr(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "198.51.100.7:5555"
if got := clientIP(r); got != "198.51.100.7" {
t.Errorf("clientIP() = %q, want %q", got, "198.51.100.7")
}
}
// TestHandleLoginRateLimited is the regression test for the
// security-audit finding that POST /auth/login had no rate limiting at
// all -- repeated attempts from the same client must eventually get a
// 429, not another 401.
func TestHandleLoginRateLimited(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
var last *httptest.ResponseRecorder
for i := 0; i < loginRateLimitMax+1; i++ {
last = doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong-password"}`, nil)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("status after exceeding the limit = %d, want 429", last.Code)
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package localauth is single-tenant mode's local username/password
// login: a real login page and session-based auth covering both /api
// and /alerting, plus a simple admin-managed user list, for deployments
// reachable over the internet that can no longer rely on Phase 0-3's
// "no auth yet" default (see /docs/architecture.md and CLAUDE.md's
// Phase 4 section for the enterprise/ SSO alternative this is not --
// this package has no tenant/RBAC-service concept, just "is this a
// valid logged-in user").
//
// Deliberately extends the existing users/tenants/tenant_memberships
// schema (0017/0018/0020_*.sql, built for Phase 4 SSO) rather than a
// parallel local_users table: tenant_memberships.role is already
// constrained to exactly authz.Role's four human values, so a local
// login gets real 4-tier roles for free, and a deployment that later
// turns on enterprise SSO has one identity graph to reconcile, not two.
// Every local user is a member of the "default" tenant only -- this
// package has no notion of provisioning additional tenants.
//
// Authorizer (authorizer.go) is what api/cmd/api/main.go wires into
// api/authz's Authorizer slot for a single-tenant deployment that wants
// real auth -- once that's non-nil, every existing RequireRole-wrapped
// route in dashboards/agents/queryapi/aiapi starts enforcing roles for
// free, no other handler file needs to change. alerting has no such
// per-route plumbing at all, so it gets its own, much smaller,
// deliberately-duplicated package (alerting/internal/sessioncheck) that
// only ever validates an already-issued session -- see that package's
// doc comment for why this isn't imported from here instead.
package localauth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
var (
ErrNotFound = errors.New("not found")
ErrUsernameTaken = errors.New("username already taken")
)
// defaultTenantID is the only tenant a local user can ever belong to --
// see the package doc comment. Matches every other single-tenant
// deployment's "default" tenant_id convention (dashboards, agents,
// alert_rules).
const defaultTenantID = "default"
type User struct {
ID string
Username string
Role authz.Role
CreatedAt time.Time
}
type Session struct {
UserID string
TenantID string
Role authz.Role
ExpiresAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateUser inserts a new local user and, in the same transaction, the
// tenant_memberships row that gives them role in the default tenant --
// a local user with no membership row would authenticate successfully
// (CreateSession has nothing that requires one) but satisfy no
// RequireRole check at all, so the two rows are never created
// separately.
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
id := uuid.NewString()
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $2, now(), now())
RETURNING created_at`,
id, username, passwordHash).Scan(&createdAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_id, role)
VALUES ($1, $2, $3, $4)`,
uuid.NewString(), defaultTenantID, id, string(role)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &User{ID: id, Username: username, Role: role, CreatedAt: createdAt}, nil
}
const listColumns = `
u.id, u.username, tm.role, u.created_at`
// ListUsers only ever returns local users (username IS NOT NULL) --
// an SSO-provisioned user with no password_hash/username set never
// appears here, since there's nothing for this package's user manager
// to do with one.
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+listColumns+`
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username IS NOT NULL
ORDER BY u.username`, defaultTenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &role, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = authz.Role(role)
out = append(out, u)
}
return out, rows.Err()
}
// GetUserForLogin returns the user and their password hash together --
// the only place this package ever reads a password_hash back out, and
// only to feed ComparePassword. Everywhere else uses User, which never
// carries the hash.
func (s *Store) GetUserForLogin(ctx context.Context, username string) (*User, string, error) {
var u User
var role, hash string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, u.password_hash, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username = $2`, defaultTenantID, username).
Scan(&u.ID, &u.Username, &hash, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
return nil, "", err
}
u.Role = authz.Role(role)
return &u, hash, nil
}
// GetUserByID backs GET /auth/session -- looking up the identity
// RequireRole already resolved and attached to the request context, to
// return its username (Session/Identity carry no username, only IDs).
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
var role string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id).
Scan(&u.ID, &u.Username, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.Role = authz.Role(role)
return &u, nil
}
// DeleteUser cascades to the user's tenant_memberships and
// local_sessions rows (both ON DELETE CASCADE) -- a deleted user's
// existing sessions stop validating immediately, not just their next
// login.
func (s *Store) DeleteUser(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1 AND username IS NOT NULL`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetPasswordHash also revokes every existing session for userID, in
// the same transaction -- Session.Role/TenantID are a snapshot taken at
// login (see 0041_create_local_sessions.sql's doc comment), so without
// this an account whose password was just reset for security reasons
// would keep any already-issued session working regardless.
func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 AND username IS NOT NULL`, hash, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
func (s *Store) CountLocalUsers(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username IS NOT NULL`).Scan(&n)
return n, err
}
// CreateSession mints a fresh opaque token for an already-authenticated
// user (login has already verified their password by the time this is
// called) and stores its hash plus a role/tenant snapshot. Returns the
// raw token -- the only time it's ever available in plaintext again
// after this call.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
uuid.NewString(), userID, tenantID, string(role), hash, time.Now().Add(ttl))
if err != nil {
return "", err
}
return raw, nil
}
// GetSession looks up an already-hashed lookup key rather than a raw
// token -- see authorizer.go, the only caller, which re-derives the
// hash from whatever the request presented before calling this.
// Deliberately does not delete an expired row itself (that's a plain
// SELECT with no side effect); the goal here is a fast, obviously-
// correct read path, not a lookup that also mutates state, so
// expired-session cleanup is a separate, simpler concern.
func (s *Store) GetSession(ctx context.Context, tokenHash string) (*Session, error) {
var sess Session
var role string
err := s.pool.QueryRow(ctx, `
SELECT user_id, tenant_id, role, expires_at
FROM local_sessions WHERE token_hash = $1`, tokenHash).
Scan(&sess.UserID, &sess.TenantID, &role, &sess.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
sess.Role = authz.Role(role)
if sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
// DeleteSessionByHash backs logout -- a no-op (not an error) if the
// session is already gone, matching logout's own "always succeeds"
// posture (handler.go's handleLogout).
func (s *Store) DeleteSessionByHash(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM local_sessions WHERE token_hash = $1`, tokenHash)
return err
}
// isUniqueViolation checks for Postgres error code 23505 (unique_violation),
// same pgconn.PgError.Code pattern rbacstore.go's SetDataSourceCredentials
// already uses for 22P02.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+157
View File
@@ -0,0 +1,157 @@
// Exercises the actual parameterized SQL in store.go against a real
// Postgres -- handler_test.go's fakeStore is hand-written to mimic this
// SQL's behavior, but can't catch a real gap like a typo in a WHERE
// clause, a wrong column name, or (the specific thing worth testing
// here) whether 0040/0041's schema/FK/CHECK constraints actually hold
// the shape this package assumes. Same "skip unless a live-Postgres env
// var is set" convention as api/dashboards/store_integration_test.go.
//
// Skipped unless LOCALAUTH_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./localauth/... -run Integration -v
package localauth
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
func integrationStore(t *testing.T) *Store {
t.Helper()
addr := os.Getenv("LOCALAUTH_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return NewStore(pool)
}
func testUsername(t *testing.T) string {
t.Helper()
return "test-" + uuid.NewString()[:8]
}
func TestIntegrationCreateAndLoginUser(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hashing password: %v", err)
}
created, err := store.CreateUser(ctx, username, hash, authz.RoleEditor)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
got, gotHash, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.ID != created.ID || got.Role != authz.RoleEditor {
t.Errorf("GetUserForLogin = %+v, want id=%s role=editor", got, created.ID)
}
if !ComparePassword(gotHash, "correct horse battery staple") {
t.Errorf("stored hash does not verify against the original password")
}
}
func TestIntegrationDuplicateUsernameRejected(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
created, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
if _, err := store.CreateUser(ctx, username, hash, authz.RoleViewer); !errors.Is(err, ErrUsernameTaken) {
t.Fatalf("second CreateUser with the same username: err = %v, want ErrUsernameTaken", err)
}
}
func TestIntegrationSessionRoundTripAndExpiry(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
sess, err := store.GetSession(ctx, hashToken(raw))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess.UserID != user.ID || sess.Role != authz.RoleAdmin {
t.Errorf("GetSession = %+v, want user_id=%s role=admin", sess, user.ID)
}
// An already-expired session (negative TTL) must not validate --
// exercises the real expires_at comparison against Postgres's own
// now(), not just Go's clock.
expiredRaw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, -time.Hour)
if err != nil {
t.Fatalf("CreateSession (expired): %v", err)
}
if _, err := store.GetSession(ctx, hashToken(expiredRaw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession on an expired session: err = %v, want ErrNotFound", err)
}
}
func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
newHash, _ := HashPassword("a-new-password")
if err := store.SetPasswordHash(ctx, user.ID, newHash); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package localauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// newOpaqueToken returns a fresh session credential: raw is what's set
// in the cookie/returned to the caller (base64url, URL/cookie-safe),
// hash is what's stored in local_sessions.token_hash. Only the hash is
// ever persisted -- same reasoning 0034_create_ingest_credentials.sql
// gives for hashing its own bearer tokens: the server only ever needs
// to check "does the presented value match," never recover the raw
// value.
func newOpaqueToken() (raw, hash string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
return raw, hashToken(raw), nil
}
// hashToken re-derives a token's hash from a raw value a caller
// presents (Authorization header or cookie), for lookup against
// local_sessions.token_hash. Plain SHA-256, not bcrypt: unlike a
// password, a session token is already high-entropy random data, not
// something an attacker could feasibly brute-force offline even from a
// leaked hash, so there's no need for bcrypt's deliberate slowness here
// -- alerting/internal/sessioncheck validates sessions on every request
// and does the same plain hash, with no bcrypt dependency at all.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}