middleware.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. package webapi
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "net/http"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/patrickmn/go-cache"
  11. "golang.org/x/time/rate"
  12. "github.com/mk6i/open-oscar-server/state"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. // contextKey is a custom type for context keys to avoid collisions.
  16. type contextKey string
  17. const (
  18. // ContextKeyAPIKey is the context key for storing the validated API key.
  19. ContextKeyAPIKey contextKey = "api_key"
  20. // contextKeyResolvedAPIKey caches an API key lookup across middlewares
  21. // handling the same request. Unexported: it is an internal memo, not
  22. // something handlers should read.
  23. contextKeyResolvedAPIKey contextKey = "resolved_api_key"
  24. )
  25. // RateLimitInfo contains rate limit metadata for a request.
  26. type RateLimitInfo struct {
  27. Limit int // Total requests allowed per window
  28. Remaining int // Requests remaining in current window
  29. Reset int64 // Unix timestamp when the window resets
  30. Allowed bool // Whether the request is allowed
  31. }
  32. // rateLimiterEntry tracks rate limiting data for a single devID.
  33. type rateLimiterEntry struct {
  34. limiter *rate.Limiter
  35. limit int
  36. windowSize time.Duration
  37. lastReset time.Time
  38. }
  39. // RateLimiter manages per-devID rate limiting for the Web API.
  40. type RateLimiter struct {
  41. limiters *cache.Cache
  42. mu sync.RWMutex
  43. windowSize time.Duration // Rate limit window size (default: 1 minute)
  44. }
  45. // NewRateLimiter creates a new rate limiter with automatic cleanup.
  46. func NewRateLimiter() *RateLimiter {
  47. // Create cache with 5 minute expiration and 10 minute cleanup interval
  48. c := cache.New(5*time.Minute, 10*time.Minute)
  49. return &RateLimiter{
  50. limiters: c,
  51. windowSize: time.Minute, // Default 1 minute window
  52. }
  53. }
  54. // CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
  55. func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
  56. if limit <= 0 {
  57. return RateLimitInfo{
  58. Reset: time.Now().Add(r.windowSize).Unix(),
  59. Allowed: true,
  60. }
  61. }
  62. r.mu.Lock()
  63. defer r.mu.Unlock()
  64. now := time.Now()
  65. // Get or create limiter entry for this devID
  66. var entry *rateLimiterEntry
  67. if val, found := r.limiters.Get(devID); found {
  68. entry = val.(*rateLimiterEntry)
  69. // Check if limit has changed
  70. if entry.limit != limit {
  71. // Recreate limiter with new limit
  72. entry.limiter = rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit)
  73. entry.limit = limit
  74. }
  75. } else {
  76. // Create new limiter with burst equal to limit (allows initial burst)
  77. entry = &rateLimiterEntry{
  78. limiter: rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit),
  79. limit: limit,
  80. windowSize: r.windowSize,
  81. lastReset: now,
  82. }
  83. r.limiters.Set(devID, entry, cache.DefaultExpiration)
  84. }
  85. // Check if request is allowed
  86. allowed := entry.limiter.Allow()
  87. // Calculate remaining requests (approximate based on tokens available)
  88. tokens := entry.limiter.Tokens()
  89. remaining := int(tokens)
  90. if remaining < 0 {
  91. remaining = 0
  92. }
  93. // Calculate reset time (next window start)
  94. resetTime := now.Add(r.windowSize).Unix()
  95. return RateLimitInfo{
  96. Limit: limit,
  97. Remaining: remaining,
  98. Reset: resetTime,
  99. Allowed: allowed,
  100. }
  101. }
  102. // AuthMiddleware provides authentication and rate limiting for Web API endpoints.
  103. type AuthMiddleware struct {
  104. Validator APIKeyValidator
  105. RateLimiter *RateLimiter
  106. Logger *slog.Logger
  107. }
  108. // NewAuthMiddleware creates a new authentication middleware instance.
  109. func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMiddleware {
  110. return &AuthMiddleware{
  111. Validator: validator,
  112. RateLimiter: NewRateLimiter(),
  113. Logger: logger,
  114. }
  115. }
  116. // RequireSession resolves the aimsid session and passes it to next. It rejects
  117. // requests whose session is missing or expired with an auth error. On success it
  118. // touches the session, sliding its expiry forward; this is the keepalive that
  119. // holds a long-polling client's session open (see the session lifecycle timeline
  120. // on state's Session manager).
  121. //
  122. // A session with a nil OSCARSession is rejected as a 500: startSession no longer
  123. // creates such sessions (anonymous guests are unsupported), so a nil is a broken
  124. // server invariant, not a client error. This lets downstream handlers treat
  125. // session.OSCARSession as non-nil.
  126. func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
  127. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  128. aimsid := r.URL.Query().Get("aimsid")
  129. if aimsid == "" {
  130. SendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
  131. return
  132. }
  133. session, err := sm.GetSession(r.Context(), aimsid)
  134. if err != nil {
  135. SendError(w, r, http.StatusUnauthorized, "invalid or expired session")
  136. return
  137. }
  138. _ = sm.TouchSession(r.Context(), aimsid)
  139. next(w, r, session)
  140. })
  141. }
  142. // Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
  143. func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
  144. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  145. // Extract API key from 'k' parameter (query or form)
  146. apiKey := r.URL.Query().Get("k")
  147. if apiKey == "" {
  148. // Try form value for POST requests
  149. apiKey = r.FormValue("k")
  150. }
  151. if apiKey == "" {
  152. SendEnvelopeStatus(w, r, http.StatusBadRequest, "required parameter 'k' is missing", m.Logger)
  153. return
  154. }
  155. // Validate API key
  156. key, r := m.resolveAPIKeyCached(r, apiKey)
  157. ctx := r.Context()
  158. if key == nil {
  159. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  160. SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
  161. return
  162. }
  163. // Check rate limit
  164. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  165. // Always add rate limit headers
  166. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  167. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  168. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  169. if !rateLimitInfo.Allowed {
  170. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  171. // Add Retry-After header
  172. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  173. if retryAfter < 1 {
  174. retryAfter = 1
  175. }
  176. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  177. SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
  178. return
  179. }
  180. // Add API key info to context for use in handlers
  181. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  182. // Log the API request
  183. m.Logger.InfoContext(ctx, "API request authenticated",
  184. "dev_id", key.DevID,
  185. "app_name", key.AppName,
  186. "method", r.Method,
  187. "path", r.URL.Path,
  188. )
  189. // Pass to next handler with enriched context
  190. next.ServeHTTP(w, r.WithContext(ctx))
  191. })
  192. }
  193. // CORSMiddleware emits CORS headers and answers preflight requests.
  194. //
  195. // It must be the OUTERMOST middleware on every route. A response that the auth
  196. // layer rejects still needs an Access-Control-Allow-Origin header: without one
  197. // the browser blocks the response, and the Web AIM client reads a status-0 empty
  198. // response as "CORS blocked" and permanently downgrades its whole request
  199. // pipeline to JSONP (aim.client.js onXhrFailed_ clears its useXhr flag and never
  200. // sets it again). A single 400/403/429 from the auth layer is enough to latch it.
  201. //
  202. // Running ahead of authentication means the key is not in the request context
  203. // yet, so this resolves it itself to find the key's origin allowlist. The lookup
  204. // is memoized on the request context, so the auth middleware downstream reuses it
  205. // rather than hitting the store a second time.
  206. func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
  207. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  208. // Only the query parameter is consulted here: reading the form would
  209. // consume a POST body before the handler sees it.
  210. key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
  211. // If there is no API key (e.g. using aimsid auth), allow all origins.
  212. // This is safe because the actual authentication is handled by the session.
  213. var allowedOrigins []string
  214. if key != nil {
  215. allowedOrigins = key.AllowedOrigins
  216. } else {
  217. // For session-based auth without API key, allow all origins
  218. // The session itself provides the security boundary
  219. m.Logger.DebugContext(r.Context(), "CORS handling for non-API-key auth (aimsid/token)")
  220. allowedOrigins = []string{"*"}
  221. }
  222. origin := r.Header.Get("Origin")
  223. // The response body varies with the request Origin, so it must not be
  224. // cached under a single key across origins.
  225. w.Header().Add("Vary", "Origin")
  226. // Check if origin is allowed
  227. if m.isOriginAllowed(origin, allowedOrigins) {
  228. if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
  229. // For wildcard, set the actual origin to allow credentials
  230. if origin != "" {
  231. w.Header().Set("Access-Control-Allow-Origin", origin)
  232. } else {
  233. w.Header().Set("Access-Control-Allow-Origin", "*")
  234. }
  235. } else {
  236. w.Header().Set("Access-Control-Allow-Origin", origin)
  237. }
  238. w.Header().Set("Access-Control-Allow-Credentials", "true")
  239. w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
  240. w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
  241. w.Header().Set("Access-Control-Max-Age", "3600")
  242. }
  243. // Handle preflight requests
  244. if r.Method == "OPTIONS" {
  245. w.WriteHeader(http.StatusNoContent)
  246. return
  247. }
  248. next.ServeHTTP(w, r)
  249. })
  250. }
  251. // isOriginAllowed checks if an origin is in the allowed list.
  252. func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
  253. // If no origins specified, allow all (for backward compatibility/development)
  254. if len(allowedOrigins) == 0 {
  255. return true
  256. }
  257. origin = strings.ToLower(origin)
  258. for _, allowed := range allowedOrigins {
  259. allowed = strings.ToLower(allowed)
  260. // Exact match
  261. if origin == allowed {
  262. return true
  263. }
  264. // Wildcard support (e.g., "*.example.com")
  265. if strings.HasPrefix(allowed, "*.") {
  266. domain := allowed[2:]
  267. if strings.HasSuffix(origin, domain) {
  268. return true
  269. }
  270. }
  271. // Allow all origins (development only)
  272. if allowed == "*" {
  273. m.Logger.Warn("wildcard origin (*) used - should not be used in production")
  274. return true
  275. }
  276. }
  277. return false
  278. }
  279. // AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
  280. // 1. aimsid (session ID) - no k required
  281. // 2. a (AOL token) - no k required
  282. // 3. ts + sig_sha256 (signed request) - no k required
  283. // 4. k (API key) - fallback if no other auth provided
  284. // This follows the Web AIM API specification where k is not required when aimsid is present.
  285. func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
  286. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  287. ctx := r.Context()
  288. // Priority 1: Check for session-based auth (aimsid)
  289. // According to the spec, when aimsid is provided, k is not required
  290. if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
  291. // The handler itself will validate the aimsid
  292. // We just need to pass the request through without requiring k
  293. m.Logger.DebugContext(ctx, "using aimsid authentication", "aimsid", aimsid[:min(16, len(aimsid))]+"...")
  294. next.ServeHTTP(w, r)
  295. return
  296. }
  297. // Priority 2: AOL token auth — user identity is in the token; k is optional.
  298. if token := r.URL.Query().Get("a"); token != "" {
  299. key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
  300. ctx := r.Context()
  301. if key == nil {
  302. devKey := r.URL.Query().Get("k")
  303. key = &state.WebAPIKey{
  304. DevID: "aim_web",
  305. DevKey: devKey,
  306. AppName: "AIM Web Client",
  307. IsActive: true,
  308. RateLimit: 600,
  309. }
  310. }
  311. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  312. m.Logger.DebugContext(ctx, "using token authentication", "dev_id", key.DevID)
  313. next.ServeHTTP(w, r.WithContext(ctx))
  314. return
  315. }
  316. // Priority 3: Check for signed request auth
  317. if ts := r.URL.Query().Get("ts"); ts != "" {
  318. if sig := r.URL.Query().Get("sig_sha256"); sig != "" {
  319. // For now, signed requests still require 'k' parameter for API key validation
  320. // The signature provides additional security on top of the API key
  321. // When full signature validation is implemented, this can be made optional
  322. m.Logger.DebugContext(ctx, "signed request detected, falling through to API key validation")
  323. // Don't return here - continue to API key validation below
  324. }
  325. }
  326. // Priority 4: Fall back to API key requirement
  327. apiKey := r.URL.Query().Get("k")
  328. if apiKey == "" {
  329. // Try form value for POST requests
  330. apiKey = r.FormValue("k")
  331. }
  332. if apiKey == "" {
  333. SendEnvelopeStatus(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter", m.Logger)
  334. return
  335. }
  336. key, r := m.resolveAPIKeyCached(r, apiKey)
  337. ctx = r.Context()
  338. if key == nil {
  339. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  340. SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
  341. return
  342. }
  343. // Check rate limit
  344. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  345. // Always add rate limit headers
  346. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  347. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  348. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  349. if !rateLimitInfo.Allowed {
  350. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  351. // Add Retry-After header
  352. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  353. if retryAfter < 1 {
  354. retryAfter = 1
  355. }
  356. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  357. SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
  358. return
  359. }
  360. // Add API key info to context for use in handlers
  361. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  362. // Log the API request
  363. m.Logger.InfoContext(ctx, "API request authenticated via key",
  364. "dev_id", key.DevID,
  365. "app_name", key.AppName,
  366. "method", r.Method,
  367. "path", r.URL.Path,
  368. )
  369. // Pass to next handler with enriched context
  370. next.ServeHTTP(w, r.WithContext(ctx))
  371. })
  372. }
  373. // resolvedAPIKey memoizes one API key lookup for the lifetime of a request.
  374. // A nil key is a cached result too: it records that devKey is unknown or
  375. // inactive, which is what lets the auth layer skip a repeat lookup.
  376. type resolvedAPIKey struct {
  377. devKey string
  378. key *state.WebAPIKey
  379. }
  380. // resolveAPIKeyCached resolves devKey, reusing the result of an earlier lookup on
  381. // the same request. It returns the key (nil when devKey is empty, unknown, or
  382. // inactive) along with a request carrying the memoized result, which callers must
  383. // pass down the chain for the caching to take effect.
  384. func (m *AuthMiddleware) resolveAPIKeyCached(r *http.Request, devKey string) (*state.WebAPIKey, *http.Request) {
  385. if devKey == "" {
  386. return nil, r
  387. }
  388. if cached, ok := r.Context().Value(contextKeyResolvedAPIKey).(*resolvedAPIKey); ok && cached.devKey == devKey {
  389. return cached.key, r
  390. }
  391. key := m.resolveAPIKey(r.Context(), devKey)
  392. ctx := context.WithValue(r.Context(), contextKeyResolvedAPIKey, &resolvedAPIKey{devKey: devKey, key: key})
  393. return key, r.WithContext(ctx)
  394. }
  395. func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *state.WebAPIKey {
  396. if devKey == "" {
  397. return nil
  398. }
  399. key, err := m.Validator.GetAPIKeyByDevKey(ctx, devKey)
  400. if err != nil || key == nil || !key.IsActive {
  401. return nil
  402. }
  403. return key
  404. }
  405. // minRetryAfter floors the Retry-After hint sent with a rate-limited response.
  406. // The computed wait can round down to nothing when a class is barely over its
  407. // limit, and a hint of zero invites an immediate retry.
  408. const minRetryAfter = 1 * time.Second
  409. // SessionHandlerFunc is the session-aware handler shape that
  410. // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
  411. type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *Session)
  412. // RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
  413. // food group.
  414. //
  415. // Such routes are limited by OSCAR itself: OSCAR charges the session's shared
  416. // per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
  417. // a user cannot dodge a limit by switching transports. Routes that reach no food
  418. // group are not limited here; edge rate limiting (a reverse proxy keyed by client
  419. // IP) is expected to cover the unauthenticated login/asset endpoints and the
  420. // authenticated bookkeeping ones.
  421. //
  422. // It lives in package webapi (rather than in server/oscar/middleware) so that its
  423. // rejection can be encoded through the same SendResponse path the handlers use,
  424. // honoring the request's JSON/JSONP/XML/AMF format.
  425. //
  426. // It only enforces the limit (the 430 rejection). Telling the client its status
  427. // changed is the job of OServiceService.MonitorRateLimits.
  428. type RateLimitMiddleware struct {
  429. snacRateLimits wire.SNACRateLimits
  430. logger *slog.Logger
  431. }
  432. // NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
  433. // same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
  434. func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
  435. return &RateLimitMiddleware{
  436. snacRateLimits: snacRateLimits,
  437. logger: logger,
  438. }
  439. }
  440. // OSCAR returns middleware that charges one unit against the OSCAR rate class
  441. // mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
  442. // HTTP counterpart of the TOC server's per-command rate check.
  443. //
  444. // A SNAC with no rate class mapping is allowed through, since refusing traffic
  445. // because the server's own table is incomplete would be worse than not limiting
  446. // it.
  447. func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
  448. return func(next SessionHandlerFunc) SessionHandlerFunc {
  449. return func(w http.ResponseWriter, r *http.Request, session *Session) {
  450. ctx := r.Context()
  451. rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
  452. if !ok {
  453. l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
  454. "foodgroup", wire.FoodGroupName(foodGroup),
  455. "subgroup", wire.SubGroupName(foodGroup, subGroup))
  456. next(w, r, session)
  457. return
  458. }
  459. sess := session.OSCARSession.Session()
  460. status := sess.EvaluateRateLimit(time.Now(), rateClassID)
  461. // Disconnect is rejected alongside Limited: EvaluateRateLimit has
  462. // already closed the account's OSCAR session by the time it returns,
  463. // so there is nothing left for the handler to act on. That close also
  464. // invalidates the aimsid (GetSession stops resolving a session whose
  465. // OSCAR instance is closed), so every subsequent request is turned
  466. // away at RequireSession rather than reaching here again.
  467. if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
  468. l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
  469. "foodgroup", wire.FoodGroupName(foodGroup),
  470. "subgroup", wire.SubGroupName(foodGroup, subGroup),
  471. "status", rateLimitStatusName(status))
  472. // A disconnected session has no aimsid left to retry with, so
  473. // there is no wait to advertise.
  474. var retryAfter time.Duration
  475. if status == wire.RateLimitStatusLimited {
  476. retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
  477. }
  478. l.sendRateLimited(w, r, retryAfter)
  479. return
  480. }
  481. next(w, r, session)
  482. }
  483. }
  484. }
  485. // retryAfterFor returns how long the client must wait for its next request on
  486. // this class to clear the limit.
  487. //
  488. // OSCAR's limiter has no fixed window: it tracks a moving average of the gap
  489. // between requests, and a request lifts the limit only once that average climbs
  490. // back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
  491. // lands the new average exactly on ClearLevel gives
  492. //
  493. // elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
  494. //
  495. // A flat hint cannot work here, because a rejected request is still charged: a
  496. // client retrying on a fixed interval drives the average toward that interval, so
  497. // any hint below the class's ClearLevel holds the average just under the bar and
  498. // the client stays limited forever. The production ICBM class clears at 5100ms,
  499. // which a 5s hint would do exactly.
  500. func retryAfterFor(rcs state.RateClassState) time.Duration {
  501. neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
  502. // Retry-After carries whole seconds, so round up: a hint that is short by a
  503. // fraction of a second reproduces the same never-clears loop. A class barely
  504. // over its limit can compute to no wait at all, hence the floor.
  505. return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
  506. }
  507. // rateLimitStatusName maps an OSCAR rate limit status onto the status string the
  508. // web client switches on. It returns "" for a status the client does not know.
  509. func rateLimitStatusName(status wire.RateLimitStatus) string {
  510. switch status {
  511. case wire.RateLimitStatusClear:
  512. return "clear"
  513. case wire.RateLimitStatusAlert:
  514. return "warn"
  515. case wire.RateLimitStatusLimited:
  516. return "limit"
  517. case wire.RateLimitStatusDisconnect:
  518. return "disconnect"
  519. default:
  520. return ""
  521. }
  522. }
  523. // sendRateLimited writes a rate limit rejection. The transport status is 200 and
  524. // the rejection lives entirely in the Web AIM API envelope's own rate limit code.
  525. //
  526. // The transport status is deliberately not 429: the AIM client's WIM request layer
  527. // (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
  528. // routed to their error handlers, which synthesize a generic "request failed"
  529. // result and never look at the body, so the envelope's 430 — which the client
  530. // swallows on the IM path in favor of the rateLimit event — would go unread and the
  531. // user would see a generic send failure instead.
  532. //
  533. // The body is encoded via SendResponse, so it honors the request's format
  534. // (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
  535. // the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
  536. // normal handler response would.
  537. //
  538. // A retryAfter of zero sends no Retry-After header, for the rejections that have
  539. // nothing to retry.
  540. func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
  541. if retryAfter > 0 {
  542. w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
  543. }
  544. resp := BaseResponse{}
  545. resp.Response.StatusCode = statusRateLimited
  546. resp.Response.StatusText = "rate limit exceeded"
  547. SendResponse(w, r, resp, l.logger)
  548. }
  549. // RequestLogger logs each request with method, path, and raw query string.
  550. func RequestLogger(logger *slog.Logger, next http.Handler) http.Handler {
  551. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  552. logger.Info("request",
  553. "method", r.Method,
  554. "path", r.URL.Path,
  555. "query", r.URL.RawQuery,
  556. )
  557. next.ServeHTTP(w, r)
  558. })
  559. }