package server import ( "context" "crypto/subtle" "errors" "fmt" "net/http" "strconv" "strings" "sync" "time" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/model" "jarl/internal/store" ) const ( sessionCookie = "jarl_session" csrfHeader = "X-CSRF-Token" ) // rateLimiter limits failed authentication attempts per key (IP or // username) with a sliding window. type rateLimiter struct { mu sync.Mutex hits map[string][]time.Time } func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} } const ( failWindow = 15 * time.Minute failsPerIP = 20 failsPerKey = 8 ) func (l *rateLimiter) recent(key string, now time.Time) []time.Time { var keep []time.Time for _, t := range l.hits[key] { if now.Sub(t) < failWindow { keep = append(keep, t) } } l.hits[key] = keep return keep } // blocked reports whether key has failed too often. func (l *rateLimiter) blocked(key string, max int) bool { l.mu.Lock() defer l.mu.Unlock() return len(l.recent(key, time.Now())) >= max } func (l *rateLimiter) fail(keys ...string) { l.mu.Lock() defer l.mu.Unlock() now := time.Now() for _, k := range keys { l.hits[k] = append(l.recent(k, now), now) } } func (l *rateLimiter) reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.hits, key) } func (l *rateLimiter) cleanup() { l.mu.Lock() defer l.mu.Unlock() now := time.Now() for k := range l.hits { if len(l.recent(k, now)) == 0 { delete(l.hits, k) } } } // throttle allows an event once per interval (every, a minute if unset) // per key, for log lines that could otherwise repeat on every request. type throttle struct { every time.Duration mu sync.Mutex last map[string]time.Time } func (t *throttle) allow(key string, now time.Time) bool { t.mu.Lock() defer t.mu.Unlock() if t.last == nil { t.last = map[string]time.Time{} } every := t.every if every == 0 { every = time.Minute } if now.Sub(t.last[key]) < every { return false } for k, v := range t.last { if now.Sub(v) > every+time.Hour { delete(t.last, k) } } t.last[key] = now return true } var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"} // authenticate identifies the caller from a bearer token or a session // cookie. For sessions, state-changing requests must carry the CSRF token. func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) { ip := s.clientIP(r) // On the local socket, root and jarld's own user need no token // (socket.go). A token, if sent, is used as anywhere else. if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" { if trustedUID(pr.UID) { return socketPrincipal(pr, r.UserAgent()), nil, nil } return nil, nil, socketDenied(pr) } if h := r.Header.Get("Authorization"); h != "" { scheme, tok, _ := strings.Cut(h, " ") if !strings.EqualFold(scheme, "Bearer") { return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"} } if s.limiter.blocked("ip:"+ip, failsPerIP) { return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"} } p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) if err != nil { s.limiter.fail("ip:" + ip) s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error()) return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} } return p, nil, nil } c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return nil, nil, errUnauthenticated } hash := auth.HashSecret(c.Value) se, err := s.store.GetSession(r.Context(), hash) if err != nil { return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"} } now := time.Now() if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle { s.store.DeleteSession(r.Context(), hash) return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"} } u, err := s.store.GetUser(r.Context(), se.UserID) if err != nil || u.Disabled { s.store.DeleteSession(r.Context(), hash) return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"} } if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { got := r.Header.Get(csrfHeader) if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 { return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"} } } if now.Sub(se.LastSeenAt) > time.Minute { s.store.TouchSession(r.Context(), hash, now) } return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil } func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) { pid, secret, err := auth.ParseToken(tok) if err != nil { return nil, err } t, err := s.store.GetTokenByPublicID(ctx, pid) if errors.Is(err, store.ErrNotFound) { return nil, errors.New("unknown token") } if err != nil { return nil, err } now := time.Now() previous := false if !auth.CheckSecret(secret, t.SecretHash) { // After a rotation with a grace period the old secret works until // the grace period ends. if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) { return nil, errors.New("wrong token secret") } if now.After(*t.PrevExpiresAt) { return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended") } previous = true } if t.RevokedAt != nil { return nil, errors.New("token was revoked") } if t.ExpiresAt != nil && now.After(*t.ExpiresAt) { return nil, errors.New("token has expired") } u, err := s.store.GetUser(ctx, t.UserID) if err != nil { return nil, err } if u.Disabled { return nil, errors.New("the token's user is disabled") } if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip { s.store.TouchToken(ctx, t.ID, ip) } s.warnTokenExpiry(t, ip, now) if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) { s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339)) } return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil } // warnTokenExpiry logs, at most once a day per token, that a token in use // expires soon (or has expired). ip is where it was used ("" when found by // the daily check). func (s *Server) warnTokenExpiry(t *model.Token, ip string, now time.Time) { if !t.ExpiresWithin(now, model.TokenExpiryWarning) || !s.expiryWarn.allow(strconv.FormatInt(t.ID, 10), now) { return } args := []any{"token_id", t.ID, "token", t.Username + "/" + t.Name, "expires_at", t.ExpiresAt.Format(time.RFC3339)} if ip != "" { args = append(args, "ip", ip) } msg := "API token expires soon; give it a new lifetime with 'jarl token rotate'" if now.After(*t.ExpiresAt) { msg = "API token has expired; anything still using it is refused (rotate it with 'jarl token rotate')" } s.log.Warn(msg, args...) } // warnExpiringTokens runs warnTokenExpiry for every token (from // housekeeping), so tokens that are rarely used are reported too. Tokens // that expired more than TokenExpiryWarning ago are no longer reported. func (s *Server) warnExpiringTokens(ctx context.Context, now time.Time) { ts, err := s.store.ListTokens(ctx, 0) if err != nil { return } for _, t := range ts { if t.ExpiresAt != nil && now.Sub(*t.ExpiresAt) < model.TokenExpiryWarning { s.warnTokenExpiry(t, "", now) } } } // authed wraps a handler with authentication and error handling. func (s *Server) authed(h handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes) p, se, err := s.authenticate(r) if err != nil { s.writeErr(w, r, err, nil) return } if p.Token != nil && p.Token.ExpiresWithin(time.Now(), model.TokenExpiryWarning) { // The CLI warns its user when it sees this. w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339)) } ctx := context.WithValue(r.Context(), keyPrincipal, p) if se != nil { ctx = context.WithValue(ctx, keySession, se) } r = r.WithContext(ctx) if err := h(w, r, p); err != nil { s.writeErr(w, r, err, nil) } } } // public wraps an unauthenticated handler. func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := h(w, r); err != nil { s.writeErr(w, r, err, nil) } } } func (s *Server) cookieSecure(r *http.Request) bool { switch s.cfg.CookieSecure { case "always": return true case "never": return false } return s.isHTTPS(r) } func (s *Server) login(w http.ResponseWriter, r *http.Request) error { var in struct { Username string `json:"username"` Password string `json:"password"` } if err := decodeJSON(r, &in); err != nil { return err } ip := s.clientIP(r) name := strings.TrimSpace(in.Username) userKey := "user:" + strings.ToLower(name) if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) { s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_blocked", "user", 0, name, "denied", "too many failed attempts") return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"} } u, err := s.store.GetUserByName(r.Context(), name) hash := "" if err == nil { hash = u.PasswordHash } ok := auth.CheckPassword(hash, in.Password) // constant time even for unknown users if err != nil || !ok || u.Disabled || u.PasswordHash == "" { s.limiter.fail("ip:"+ip, userKey) reason := "wrong username or password" if err == nil && u.Disabled && ok { reason = "account disabled" } s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} } s.limiter.reset(userKey) id, csrf := auth.NewSessionID() now := time.Now() se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()} if err := s.store.CreateSession(r.Context(), se); err != nil { return err } s.store.TouchLogin(r.Context(), u.ID) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt}) p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()} s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "") writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) return nil } func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if se, ok := r.Context().Value(keySession).(*model.Session); ok { s.store.DeleteSession(r.Context(), se.IDHash) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1}) s.eng.Audit(r.Context(), p, "auth.logout", "user", p.User.ID, p.Name(), "success", "") w.WriteHeader(http.StatusNoContent) return nil }