auth.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. package middleware
  2. import (
  3. "context"
  4. "encoding/json"
  5. "encoding/xml"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "time"
  12. "github.com/patrickmn/go-cache"
  13. "golang.org/x/time/rate"
  14. "github.com/mk6i/open-oscar-server/state"
  15. )
  16. // contextKey is a custom type for context keys to avoid collisions.
  17. type contextKey string
  18. const (
  19. // ContextKeyAPIKey is the context key for storing the validated API key.
  20. ContextKeyAPIKey contextKey = "api_key"
  21. // ContextKeyDevID is the context key for storing the developer ID.
  22. ContextKeyDevID contextKey = "dev_id"
  23. // contextKeyResolvedAPIKey caches an API key lookup across middlewares
  24. // handling the same request. Unexported: it is an internal memo, not
  25. // something handlers should read.
  26. contextKeyResolvedAPIKey contextKey = "resolved_api_key"
  27. )
  28. // APIKeyValidator defines methods for validating Web API keys.
  29. type APIKeyValidator interface {
  30. // GetAPIKeyByDevKey retrieves and validates an API key by its dev_key value.
  31. GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
  32. // UpdateLastUsed updates the last_used timestamp for an API key.
  33. UpdateLastUsed(ctx context.Context, devKey string) error
  34. }
  35. // RateLimitInfo contains rate limit metadata for a request.
  36. type RateLimitInfo struct {
  37. Limit int // Total requests allowed per window
  38. Remaining int // Requests remaining in current window
  39. Reset int64 // Unix timestamp when the window resets
  40. Allowed bool // Whether the request is allowed
  41. }
  42. // rateLimiterEntry tracks rate limiting data for a single devID.
  43. type rateLimiterEntry struct {
  44. limiter *rate.Limiter
  45. limit int
  46. windowSize time.Duration
  47. lastReset time.Time
  48. }
  49. // RateLimiter manages per-devID rate limiting for the Web API.
  50. type RateLimiter struct {
  51. limiters *cache.Cache
  52. mu sync.RWMutex
  53. windowSize time.Duration // Rate limit window size (default: 1 minute)
  54. }
  55. // NewRateLimiter creates a new rate limiter with automatic cleanup.
  56. func NewRateLimiter() *RateLimiter {
  57. // Create cache with 5 minute expiration and 10 minute cleanup interval
  58. c := cache.New(5*time.Minute, 10*time.Minute)
  59. return &RateLimiter{
  60. limiters: c,
  61. windowSize: time.Minute, // Default 1 minute window
  62. }
  63. }
  64. // CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
  65. func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
  66. r.mu.Lock()
  67. defer r.mu.Unlock()
  68. now := time.Now()
  69. // Get or create limiter entry for this devID
  70. var entry *rateLimiterEntry
  71. if val, found := r.limiters.Get(devID); found {
  72. entry = val.(*rateLimiterEntry)
  73. // Check if limit has changed
  74. if entry.limit != limit {
  75. // Recreate limiter with new limit
  76. entry.limiter = rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit)
  77. entry.limit = limit
  78. }
  79. } else {
  80. // Create new limiter with burst equal to limit (allows initial burst)
  81. entry = &rateLimiterEntry{
  82. limiter: rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit),
  83. limit: limit,
  84. windowSize: r.windowSize,
  85. lastReset: now,
  86. }
  87. r.limiters.Set(devID, entry, cache.DefaultExpiration)
  88. }
  89. // Check if request is allowed
  90. allowed := entry.limiter.Allow()
  91. // Calculate remaining requests (approximate based on tokens available)
  92. tokens := entry.limiter.Tokens()
  93. remaining := int(tokens)
  94. if remaining < 0 {
  95. remaining = 0
  96. }
  97. // Calculate reset time (next window start)
  98. resetTime := now.Add(r.windowSize).Unix()
  99. return RateLimitInfo{
  100. Limit: limit,
  101. Remaining: remaining,
  102. Reset: resetTime,
  103. Allowed: allowed,
  104. }
  105. }
  106. // AuthMiddleware provides authentication and rate limiting for Web API endpoints.
  107. type AuthMiddleware struct {
  108. Validator APIKeyValidator
  109. RateLimiter *RateLimiter
  110. Logger *slog.Logger
  111. }
  112. // NewAuthMiddleware creates a new authentication middleware instance.
  113. func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMiddleware {
  114. return &AuthMiddleware{
  115. Validator: validator,
  116. RateLimiter: NewRateLimiter(),
  117. Logger: logger,
  118. }
  119. }
  120. // WebAPISessionResolver resolves and refreshes Web API sessions by aimsid.
  121. type WebAPISessionResolver interface {
  122. GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error)
  123. TouchSession(ctx context.Context, aimsid string) error
  124. }
  125. // RequireSession resolves the aimsid session and passes it to next. It rejects
  126. // requests whose session is missing or expired with an auth error. On success it
  127. // touches the session, sliding its expiry forward; this is the keepalive that
  128. // holds a long-polling client's session open (see the session lifecycle timeline
  129. // on state's WebAPISession manager).
  130. //
  131. // A session with a nil OSCARSession is rejected as a 500: startSession no longer
  132. // creates such sessions (anonymous guests are unsupported), so a nil is a broken
  133. // server invariant, not a client error. This lets downstream handlers treat
  134. // session.OSCARSession as non-nil.
  135. func (m *AuthMiddleware) RequireSession(sm WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
  136. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  137. aimsid := r.URL.Query().Get("aimsid")
  138. if aimsid == "" {
  139. m.sendSessionError(w, r, http.StatusBadRequest, "missing aimsid parameter")
  140. return
  141. }
  142. session, err := sm.GetSession(r.Context(), aimsid)
  143. if err != nil {
  144. m.sendSessionError(w, r, http.StatusUnauthorized, "invalid or expired session")
  145. return
  146. }
  147. _ = sm.TouchSession(r.Context(), aimsid)
  148. next(w, r, session)
  149. })
  150. }
  151. // sendSessionError writes a Web AIM API error envelope with the given HTTP
  152. // status, or as a JSONP callback when the client requested one.
  153. func (m *AuthMiddleware) sendSessionError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  154. m.writeErrorEnvelope(w, r, statusCode, message, true)
  155. }
  156. // Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
  157. func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
  158. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  159. // Extract API key from 'k' parameter (query or form)
  160. apiKey := r.URL.Query().Get("k")
  161. if apiKey == "" {
  162. // Try form value for POST requests
  163. apiKey = r.FormValue("k")
  164. }
  165. if apiKey == "" {
  166. m.sendErrorResponse(w, r, http.StatusBadRequest, "required parameter 'k' is missing")
  167. return
  168. }
  169. // Validate API key
  170. key, r := m.resolveAPIKeyCached(r, apiKey)
  171. ctx := r.Context()
  172. if key == nil {
  173. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  174. m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
  175. return
  176. }
  177. // Check rate limit
  178. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  179. // Always add rate limit headers
  180. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  181. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  182. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  183. if !rateLimitInfo.Allowed {
  184. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  185. // Add Retry-After header
  186. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  187. if retryAfter < 1 {
  188. retryAfter = 1
  189. }
  190. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  191. m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
  192. return
  193. }
  194. // Update last used timestamp asynchronously
  195. go func() {
  196. if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
  197. m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
  198. }
  199. }()
  200. // Add API key info to context for use in handlers
  201. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  202. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  203. // Log the API request
  204. m.Logger.InfoContext(ctx, "API request authenticated",
  205. "dev_id", key.DevID,
  206. "app_name", key.AppName,
  207. "method", r.Method,
  208. "path", r.URL.Path,
  209. )
  210. // Pass to next handler with enriched context
  211. next.ServeHTTP(w, r.WithContext(ctx))
  212. })
  213. }
  214. // CORSMiddleware emits CORS headers and answers preflight requests.
  215. //
  216. // It must be the OUTERMOST middleware on every route. A response that the auth
  217. // layer rejects still needs an Access-Control-Allow-Origin header: without one
  218. // the browser blocks the response, and the Web AIM client reads a status-0 empty
  219. // response as "CORS blocked" and permanently downgrades its whole request
  220. // pipeline to JSONP (aim.client.js onXhrFailed_ clears its useXhr flag and never
  221. // sets it again). A single 400/403/429 from the auth layer is enough to latch it.
  222. //
  223. // Running ahead of authentication means the key is not in the request context
  224. // yet, so this resolves it itself to find the key's origin allowlist. The lookup
  225. // is memoized on the request context, so the auth middleware downstream reuses it
  226. // rather than hitting the store a second time.
  227. func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
  228. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  229. // Only the query parameter is consulted here: reading the form would
  230. // consume a POST body before the handler sees it.
  231. key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
  232. // If there is no API key (e.g. using aimsid auth), allow all origins.
  233. // This is safe because the actual authentication is handled by the session.
  234. var allowedOrigins []string
  235. if key != nil {
  236. allowedOrigins = key.AllowedOrigins
  237. } else {
  238. // For session-based auth without API key, allow all origins
  239. // The session itself provides the security boundary
  240. m.Logger.DebugContext(r.Context(), "CORS handling for non-API-key auth (aimsid/token)")
  241. allowedOrigins = []string{"*"}
  242. }
  243. origin := r.Header.Get("Origin")
  244. // The response body varies with the request Origin, so it must not be
  245. // cached under a single key across origins.
  246. w.Header().Add("Vary", "Origin")
  247. // Check if origin is allowed
  248. if m.isOriginAllowed(origin, allowedOrigins) {
  249. if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
  250. // For wildcard, set the actual origin to allow credentials
  251. if origin != "" {
  252. w.Header().Set("Access-Control-Allow-Origin", origin)
  253. } else {
  254. w.Header().Set("Access-Control-Allow-Origin", "*")
  255. }
  256. } else {
  257. w.Header().Set("Access-Control-Allow-Origin", origin)
  258. }
  259. w.Header().Set("Access-Control-Allow-Credentials", "true")
  260. w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
  261. w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
  262. w.Header().Set("Access-Control-Max-Age", "3600")
  263. }
  264. // Handle preflight requests
  265. if r.Method == "OPTIONS" {
  266. w.WriteHeader(http.StatusNoContent)
  267. return
  268. }
  269. next.ServeHTTP(w, r)
  270. })
  271. }
  272. // isOriginAllowed checks if an origin is in the allowed list.
  273. func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
  274. // If no origins specified, allow all (for backward compatibility/development)
  275. if len(allowedOrigins) == 0 {
  276. return true
  277. }
  278. origin = strings.ToLower(origin)
  279. for _, allowed := range allowedOrigins {
  280. allowed = strings.ToLower(allowed)
  281. // Exact match
  282. if origin == allowed {
  283. return true
  284. }
  285. // Wildcard support (e.g., "*.example.com")
  286. if strings.HasPrefix(allowed, "*.") {
  287. domain := allowed[2:]
  288. if strings.HasSuffix(origin, domain) {
  289. return true
  290. }
  291. }
  292. // Allow all origins (development only)
  293. if allowed == "*" {
  294. m.Logger.Warn("wildcard origin (*) used - should not be used in production")
  295. return true
  296. }
  297. }
  298. return false
  299. }
  300. // sendErrorResponse sends a Web AIM API error envelope, with JSONP support when
  301. // requested. The HTTP status stays 200 and the real status travels in the
  302. // envelope, which is where the Web AIM client reads it from.
  303. func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  304. m.writeErrorEnvelope(w, r, statusCode, message, false)
  305. }
  306. // writeErrorEnvelope marshals a Web AIM API error envelope and writes it as JSON
  307. // or, when the client asked for a callback, as JSONP.
  308. //
  309. // A JSONP error is always sent with HTTP 200 regardless of httpStatus: browsers
  310. // do not execute the body of a <script> tag that came back with a 4xx or 5xx, so
  311. // a status-carrying JSONP error never reaches the callback and surfaces in the
  312. // client as the generic "Failed to load script tag, probably malformed JS at
  313. // that url" instead of the real statusText.
  314. func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Request, statusCode int, message string, httpStatus bool) {
  315. envelope := map[string]any{
  316. "statusCode": statusCode,
  317. "statusText": message,
  318. // Callbacks that reach response.data on a failure throw a TypeError when
  319. // it is absent, so the envelope carries an empty one even here.
  320. "data": map[string]any{},
  321. }
  322. // The client indexes JSONP replies by response.requestId and discards any
  323. // reply that lacks one, leaving the request pending until it times out.
  324. if id := r.URL.Query().Get("r"); id != "" {
  325. envelope["requestId"] = id
  326. }
  327. body, err := json.Marshal(map[string]any{"response": envelope})
  328. if err != nil {
  329. m.Logger.Error("failed to encode error response", "err", err.Error())
  330. http.Error(w, "internal server error", http.StatusInternalServerError)
  331. return
  332. }
  333. // The callback outranks the format: a client on the <script> transport needs
  334. // executable JS back whatever "f" says, and gets a script load failure
  335. // otherwise.
  336. if callback := jsonpCallback(r); callback != "" && isValidJSONPCallback(callback) {
  337. w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
  338. _, _ = w.Write([]byte(callback))
  339. _, _ = w.Write([]byte("("))
  340. _, _ = w.Write(body)
  341. _, _ = w.Write([]byte(");"))
  342. return
  343. }
  344. // An XML client cannot parse a JSON error; it reports an unreadable response
  345. // rather than this statusText.
  346. if requestFormat(r) == "xml" {
  347. m.writeXMLErrorEnvelope(w, statusCode, message, httpStatus)
  348. return
  349. }
  350. w.Header().Set("Content-Type", "application/json")
  351. if httpStatus {
  352. w.WriteHeader(statusCode)
  353. }
  354. _, _ = w.Write(body)
  355. }
  356. // xmlErrorEnvelope is the error envelope XML clients read, rooted at the
  357. // response itself where JSON nests it under a "response" key.
  358. type xmlErrorEnvelope struct {
  359. XMLName xml.Name `xml:"response"`
  360. StatusCode int `xml:"statusCode"`
  361. StatusText string `xml:"statusText"`
  362. Data struct{} `xml:"data"`
  363. }
  364. func (m *AuthMiddleware) writeXMLErrorEnvelope(w http.ResponseWriter, statusCode int, message string, httpStatus bool) {
  365. body, err := xml.Marshal(xmlErrorEnvelope{StatusCode: statusCode, StatusText: message})
  366. if err != nil {
  367. m.Logger.Error("failed to encode XML error response", "err", err.Error())
  368. http.Error(w, "internal server error", http.StatusInternalServerError)
  369. return
  370. }
  371. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  372. if httpStatus {
  373. w.WriteHeader(statusCode)
  374. }
  375. _, _ = w.Write([]byte(xml.Header))
  376. _, _ = w.Write(body)
  377. }
  378. // requestFormat returns the format the client asked for. A POST sends "f" in its
  379. // body, as clientLogin does, so the query string alone does not answer it.
  380. func requestFormat(r *http.Request) string {
  381. format := strings.ToLower(r.URL.Query().Get("f"))
  382. if format == "" && r.Method == http.MethodPost {
  383. _ = r.ParseForm()
  384. format = strings.ToLower(r.FormValue("f"))
  385. }
  386. return format
  387. }
  388. func jsonpCallback(r *http.Request) string {
  389. if callback := r.URL.Query().Get("c"); callback != "" {
  390. return callback
  391. }
  392. return r.URL.Query().Get("callback")
  393. }
  394. func isValidJSONPCallback(callback string) bool {
  395. if len(callback) == 0 || len(callback) > 100 {
  396. return false
  397. }
  398. for _, r := range callback {
  399. if (r < 'a' || r > 'z') &&
  400. (r < 'A' || r > 'Z') &&
  401. (r < '0' || r > '9') &&
  402. r != '_' && r != '$' && r != '.' {
  403. return false
  404. }
  405. }
  406. return true
  407. }
  408. // min returns the minimum of two integers.
  409. func min(a, b int) int {
  410. if a < b {
  411. return a
  412. }
  413. return b
  414. }
  415. // AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
  416. // 1. aimsid (session ID) - no k required
  417. // 2. a (AOL token) - no k required
  418. // 3. ts + sig_sha256 (signed request) - no k required
  419. // 4. k (API key) - fallback if no other auth provided
  420. // This follows the Web AIM API specification where k is not required when aimsid is present.
  421. func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
  422. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  423. ctx := r.Context()
  424. // Priority 1: Check for session-based auth (aimsid)
  425. // According to the spec, when aimsid is provided, k is not required
  426. if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
  427. // The handler itself will validate the aimsid
  428. // We just need to pass the request through without requiring k
  429. m.Logger.DebugContext(ctx, "using aimsid authentication", "aimsid", aimsid[:min(16, len(aimsid))]+"...")
  430. next.ServeHTTP(w, r)
  431. return
  432. }
  433. // Priority 2: AOL token auth — user identity is in the token; k is optional.
  434. if token := r.URL.Query().Get("a"); token != "" {
  435. key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
  436. ctx := r.Context()
  437. if key == nil {
  438. devKey := r.URL.Query().Get("k")
  439. key = &state.WebAPIKey{
  440. DevID: "aim_web",
  441. DevKey: devKey,
  442. AppName: "AIM Web Client",
  443. IsActive: true,
  444. RateLimit: 600,
  445. }
  446. }
  447. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  448. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  449. m.Logger.DebugContext(ctx, "using token authentication", "dev_id", key.DevID)
  450. next.ServeHTTP(w, r.WithContext(ctx))
  451. return
  452. }
  453. // Priority 3: Check for signed request auth
  454. if ts := r.URL.Query().Get("ts"); ts != "" {
  455. if sig := r.URL.Query().Get("sig_sha256"); sig != "" {
  456. // For now, signed requests still require 'k' parameter for API key validation
  457. // The signature provides additional security on top of the API key
  458. // When full signature validation is implemented, this can be made optional
  459. m.Logger.DebugContext(ctx, "signed request detected, falling through to API key validation")
  460. // Don't return here - continue to API key validation below
  461. }
  462. }
  463. // Priority 4: Fall back to API key requirement
  464. apiKey := r.URL.Query().Get("k")
  465. if apiKey == "" {
  466. // Try form value for POST requests
  467. apiKey = r.FormValue("k")
  468. }
  469. if apiKey == "" {
  470. m.sendErrorResponse(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
  471. return
  472. }
  473. key, r := m.resolveAPIKeyCached(r, apiKey)
  474. ctx = r.Context()
  475. if key == nil {
  476. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  477. m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
  478. return
  479. }
  480. // Check rate limit
  481. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  482. // Always add rate limit headers
  483. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  484. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  485. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  486. if !rateLimitInfo.Allowed {
  487. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  488. // Add Retry-After header
  489. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  490. if retryAfter < 1 {
  491. retryAfter = 1
  492. }
  493. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  494. m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
  495. return
  496. }
  497. // Update last used timestamp asynchronously
  498. go func() {
  499. if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
  500. m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
  501. }
  502. }()
  503. // Add API key info to context for use in handlers
  504. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  505. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  506. // Log the API request
  507. m.Logger.InfoContext(ctx, "API request authenticated via key",
  508. "dev_id", key.DevID,
  509. "app_name", key.AppName,
  510. "method", r.Method,
  511. "path", r.URL.Path,
  512. )
  513. // Pass to next handler with enriched context
  514. next.ServeHTTP(w, r.WithContext(ctx))
  515. })
  516. }
  517. // resolvedAPIKey memoizes one API key lookup for the lifetime of a request.
  518. // A nil key is a cached result too: it records that devKey is unknown or
  519. // inactive, which is what lets the auth layer skip a repeat lookup.
  520. type resolvedAPIKey struct {
  521. devKey string
  522. key *state.WebAPIKey
  523. }
  524. // resolveAPIKeyCached resolves devKey, reusing the result of an earlier lookup on
  525. // the same request. It returns the key (nil when devKey is empty, unknown, or
  526. // inactive) along with a request carrying the memoized result, which callers must
  527. // pass down the chain for the caching to take effect.
  528. func (m *AuthMiddleware) resolveAPIKeyCached(r *http.Request, devKey string) (*state.WebAPIKey, *http.Request) {
  529. if devKey == "" {
  530. return nil, r
  531. }
  532. if cached, ok := r.Context().Value(contextKeyResolvedAPIKey).(*resolvedAPIKey); ok && cached.devKey == devKey {
  533. return cached.key, r
  534. }
  535. key := m.resolveAPIKey(r.Context(), devKey)
  536. ctx := context.WithValue(r.Context(), contextKeyResolvedAPIKey, &resolvedAPIKey{devKey: devKey, key: key})
  537. return key, r.WithContext(ctx)
  538. }
  539. func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *state.WebAPIKey {
  540. if devKey == "" {
  541. return nil
  542. }
  543. key, err := m.Validator.GetAPIKeyByDevKey(ctx, devKey)
  544. if err != nil || key == nil || !key.IsActive {
  545. return nil
  546. }
  547. return key
  548. }