middleware.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. package webapi
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "net/http"
  6. "time"
  7. "github.com/mk6i/open-oscar-server/state"
  8. "github.com/mk6i/open-oscar-server/wire"
  9. )
  10. // AuthMiddleware resolves the aimsid session for Web API endpoints.
  11. type AuthMiddleware struct {
  12. Logger *slog.Logger
  13. }
  14. // NewAuthMiddleware creates a new session middleware instance.
  15. func NewAuthMiddleware(logger *slog.Logger) *AuthMiddleware {
  16. return &AuthMiddleware{
  17. Logger: logger,
  18. }
  19. }
  20. // RequireSession resolves the aimsid session and passes it to next. It rejects
  21. // requests whose session is missing or expired with an auth error. On success it
  22. // touches the session, sliding its expiry forward; this is the keepalive that
  23. // holds a long-polling client's session open (see the session lifecycle timeline
  24. // on state's Session manager).
  25. //
  26. // startSession is the only thing that creates a session and it always attaches an
  27. // OSCAR session (anonymous guests are unsupported), so downstream handlers treat
  28. // session.OSCARSession as non-nil.
  29. func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
  30. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  31. aimsid := param(r, "aimsid")
  32. if aimsid == "" {
  33. SendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
  34. return
  35. }
  36. session, err := sm.GetSession(r.Context(), aimsid)
  37. if err != nil {
  38. SendError(w, r, http.StatusUnauthorized, "invalid or expired session")
  39. return
  40. }
  41. _ = sm.TouchSession(r.Context(), aimsid)
  42. next(w, r, session)
  43. })
  44. }
  45. // SessionHandlerFunc is the session-aware handler shape that
  46. // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
  47. type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *Session)
  48. // RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
  49. // food group.
  50. //
  51. // Such routes are limited by OSCAR itself: OSCAR charges the session's shared
  52. // per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
  53. // a user cannot dodge a limit by switching transports. Routes that reach no food
  54. // group are not limited here; edge rate limiting (a reverse proxy keyed by client
  55. // IP) is expected to cover the unauthenticated login/asset endpoints and the
  56. // authenticated bookkeeping ones.
  57. //
  58. // It lives in package webapi (rather than in server/oscar/middleware) so that its
  59. // rejection can be encoded through the same SendResponse path the handlers use,
  60. // honoring the request's JSON/JSONP/XML/AMF format.
  61. //
  62. // It only enforces the limit (the 430 rejection). Telling the client its status
  63. // changed is the job of OServiceService.MonitorRateLimits.
  64. type RateLimitMiddleware struct {
  65. snacRateLimits wire.SNACRateLimits
  66. logger *slog.Logger
  67. }
  68. // NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
  69. // same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
  70. func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
  71. return &RateLimitMiddleware{
  72. snacRateLimits: snacRateLimits,
  73. logger: logger,
  74. }
  75. }
  76. // OSCAR returns middleware that charges one unit against the OSCAR rate class
  77. // mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
  78. // HTTP counterpart of the TOC server's per-command rate check.
  79. //
  80. // A SNAC with no rate class mapping is allowed through, since refusing traffic
  81. // because the server's own table is incomplete would be worse than not limiting
  82. // it.
  83. func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
  84. return func(next SessionHandlerFunc) SessionHandlerFunc {
  85. return func(w http.ResponseWriter, r *http.Request, session *Session) {
  86. ctx := r.Context()
  87. rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
  88. if !ok {
  89. l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
  90. "foodgroup", wire.FoodGroupName(foodGroup),
  91. "subgroup", wire.SubGroupName(foodGroup, subGroup))
  92. next(w, r, session)
  93. return
  94. }
  95. sess := session.OSCARSession.Session()
  96. status := sess.EvaluateRateLimit(time.Now(), rateClassID)
  97. // Disconnect is rejected alongside Limited: EvaluateRateLimit has
  98. // already closed the account's OSCAR session by the time it returns,
  99. // so there is nothing left for the handler to act on. That close also
  100. // invalidates the aimsid (GetSession stops resolving a session whose
  101. // OSCAR instance is closed), so every subsequent request is turned
  102. // away at RequireSession rather than reaching here again.
  103. if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
  104. l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
  105. "foodgroup", wire.FoodGroupName(foodGroup),
  106. "subgroup", wire.SubGroupName(foodGroup, subGroup),
  107. "status", rateLimitStatusName(status))
  108. // A disconnected session has no aimsid left to retry with, so
  109. // there is no wait to advertise.
  110. var retryAfter time.Duration
  111. if status == wire.RateLimitStatusLimited {
  112. retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
  113. }
  114. l.sendRateLimited(w, r, retryAfter)
  115. return
  116. }
  117. next(w, r, session)
  118. }
  119. }
  120. }
  121. // minRetryAfter floors the Retry-After hint sent with a rate-limited response.
  122. // The computed wait can round down to nothing when a class is barely over its
  123. // limit, and a hint of zero invites an immediate retry.
  124. const minRetryAfter = 1 * time.Second
  125. // retryAfterFor returns how long the client must wait for its next request on
  126. // this class to clear the limit.
  127. //
  128. // OSCAR's limiter has no fixed window: it tracks a moving average of the gap
  129. // between requests, and a request lifts the limit only once that average climbs
  130. // back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
  131. // lands the new average exactly on ClearLevel gives
  132. //
  133. // elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
  134. //
  135. // A flat hint cannot work here, because a rejected request is still charged: a
  136. // client retrying on a fixed interval drives the average toward that interval, so
  137. // any hint below the class's ClearLevel holds the average just under the bar and
  138. // the client stays limited forever. The production ICBM class clears at 5100ms,
  139. // which a 5s hint would do exactly.
  140. func retryAfterFor(rcs state.RateClassState) time.Duration {
  141. neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
  142. // Retry-After carries whole seconds, so round up: a hint that is short by a
  143. // fraction of a second reproduces the same never-clears loop. A class barely
  144. // over its limit can compute to no wait at all, hence the floor.
  145. return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
  146. }
  147. // rateLimitStatusName maps an OSCAR rate limit status onto the status string the
  148. // web client switches on. It returns "" for a status the client does not know.
  149. func rateLimitStatusName(status wire.RateLimitStatus) string {
  150. switch status {
  151. case wire.RateLimitStatusClear:
  152. return "clear"
  153. case wire.RateLimitStatusAlert:
  154. return "warn"
  155. case wire.RateLimitStatusLimited:
  156. return "limit"
  157. case wire.RateLimitStatusDisconnect:
  158. return "disconnect"
  159. default:
  160. return ""
  161. }
  162. }
  163. // sendRateLimited writes a rate limit rejection. The transport status is 200 and
  164. // the rejection lives entirely in the Web AIM API envelope's own rate limit code.
  165. //
  166. // The transport status is deliberately not 429: the AIM client's WIM request layer
  167. // (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
  168. // routed to their error handlers, which synthesize a generic "request failed"
  169. // result and never look at the body, so the envelope's 430 — which the client
  170. // swallows on the IM path in favor of the rateLimit event — would go unread and the
  171. // user would see a generic send failure instead.
  172. //
  173. // The body is encoded via SendResponse, so it honors the request's format
  174. // (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
  175. // the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
  176. // normal handler response would.
  177. //
  178. // A retryAfter of zero sends no Retry-After header, for the rejections that have
  179. // nothing to retry.
  180. func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
  181. if retryAfter > 0 {
  182. w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
  183. }
  184. resp := BaseResponse{}
  185. resp.Response.StatusCode = statusRateLimited
  186. resp.Response.StatusText = "rate limit exceeded"
  187. SendResponse(w, r, resp, l.logger)
  188. }
  189. // RequestLogger logs each request with method, path, and raw query string.
  190. func RequestLogger(logger *slog.Logger, next http.Handler) http.Handler {
  191. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  192. logger.Info("request",
  193. "method", r.Method,
  194. "path", r.URL.Path,
  195. "query", r.URL.RawQuery,
  196. )
  197. next.ServeHTTP(w, r)
  198. })
  199. }