Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
364 lines
12 KiB
Go
364 lines
12 KiB
Go
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))
|
|
}
|
|
if s.setupOnly(p) && !setupOnlyAllowed(r) {
|
|
s.writeErr(w, r, errSetupOnly, nil)
|
|
return
|
|
}
|
|
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"}
|
|
}
|
|
if u.HasTOTP() {
|
|
// The password was right; the session comes with the code
|
|
// (POST /auth/login/totp).
|
|
s.loginChallenge(w, u)
|
|
return nil
|
|
}
|
|
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(r.Context(), 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
|
|
}
|