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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user