auth.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. package middleware
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/patrickmn/go-cache"
  12. "golang.org/x/time/rate"
  13. "github.com/mk6i/open-oscar-server/state"
  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. // ContextKeyDevID is the context key for storing the developer ID.
  21. ContextKeyDevID contextKey = "dev_id"
  22. )
  23. // APIKeyValidator defines methods for validating Web API keys.
  24. type APIKeyValidator interface {
  25. // GetAPIKeyByDevKey retrieves and validates an API key by its dev_key value.
  26. GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
  27. // UpdateLastUsed updates the last_used timestamp for an API key.
  28. UpdateLastUsed(ctx context.Context, devKey string) error
  29. }
  30. // RateLimitInfo contains rate limit metadata for a request.
  31. type RateLimitInfo struct {
  32. Limit int // Total requests allowed per window
  33. Remaining int // Requests remaining in current window
  34. Reset int64 // Unix timestamp when the window resets
  35. Allowed bool // Whether the request is allowed
  36. }
  37. // rateLimiterEntry tracks rate limiting data for a single devID.
  38. type rateLimiterEntry struct {
  39. limiter *rate.Limiter
  40. limit int
  41. windowSize time.Duration
  42. lastReset time.Time
  43. }
  44. // RateLimiter manages per-devID rate limiting for the Web API.
  45. type RateLimiter struct {
  46. limiters *cache.Cache
  47. mu sync.RWMutex
  48. windowSize time.Duration // Rate limit window size (default: 1 minute)
  49. }
  50. // NewRateLimiter creates a new rate limiter with automatic cleanup.
  51. func NewRateLimiter() *RateLimiter {
  52. // Create cache with 5 minute expiration and 10 minute cleanup interval
  53. c := cache.New(5*time.Minute, 10*time.Minute)
  54. return &RateLimiter{
  55. limiters: c,
  56. windowSize: time.Minute, // Default 1 minute window
  57. }
  58. }
  59. // CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
  60. func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
  61. r.mu.Lock()
  62. defer r.mu.Unlock()
  63. now := time.Now()
  64. // Get or create limiter entry for this devID
  65. var entry *rateLimiterEntry
  66. if val, found := r.limiters.Get(devID); found {
  67. entry = val.(*rateLimiterEntry)
  68. // Check if limit has changed
  69. if entry.limit != limit {
  70. // Recreate limiter with new limit
  71. entry.limiter = rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit)
  72. entry.limit = limit
  73. }
  74. } else {
  75. // Create new limiter with burst equal to limit (allows initial burst)
  76. entry = &rateLimiterEntry{
  77. limiter: rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit),
  78. limit: limit,
  79. windowSize: r.windowSize,
  80. lastReset: now,
  81. }
  82. r.limiters.Set(devID, entry, cache.DefaultExpiration)
  83. }
  84. // Check if request is allowed
  85. allowed := entry.limiter.Allow()
  86. // Calculate remaining requests (approximate based on tokens available)
  87. tokens := entry.limiter.Tokens()
  88. remaining := int(tokens)
  89. if remaining < 0 {
  90. remaining = 0
  91. }
  92. // Calculate reset time (next window start)
  93. resetTime := now.Add(r.windowSize).Unix()
  94. return RateLimitInfo{
  95. Limit: limit,
  96. Remaining: remaining,
  97. Reset: resetTime,
  98. Allowed: allowed,
  99. }
  100. }
  101. // AuthMiddleware provides authentication and rate limiting for Web API endpoints.
  102. type AuthMiddleware struct {
  103. Validator APIKeyValidator
  104. RateLimiter *RateLimiter
  105. Logger *slog.Logger
  106. }
  107. // NewAuthMiddleware creates a new authentication middleware instance.
  108. func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMiddleware {
  109. return &AuthMiddleware{
  110. Validator: validator,
  111. RateLimiter: NewRateLimiter(),
  112. Logger: logger,
  113. }
  114. }
  115. // WebAPISessionResolver resolves and refreshes Web API sessions by aimsid.
  116. type WebAPISessionResolver interface {
  117. GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error)
  118. TouchSession(ctx context.Context, aimsid string) error
  119. }
  120. // RequireSession resolves the aimsid session and passes it to next. It rejects
  121. // requests whose session is missing or expired with an auth error. On success it
  122. // touches the session, sliding its expiry forward; this is the keepalive that
  123. // holds a long-polling client's session open (see the session lifecycle timeline
  124. // on state's WebAPISession manager).
  125. //
  126. // A session with a nil OSCARSession is rejected as a 500: startSession no longer
  127. // creates such sessions (anonymous guests are unsupported), so a nil is a broken
  128. // server invariant, not a client error. This lets downstream handlers treat
  129. // session.OSCARSession as non-nil.
  130. func (m *AuthMiddleware) RequireSession(sm WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
  131. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  132. aimsid := r.URL.Query().Get("aimsid")
  133. if aimsid == "" {
  134. m.sendSessionError(w, http.StatusBadRequest, "missing aimsid parameter")
  135. return
  136. }
  137. session, err := sm.GetSession(r.Context(), aimsid)
  138. if err != nil {
  139. m.sendSessionError(w, http.StatusUnauthorized, "invalid or expired session")
  140. return
  141. }
  142. // startSession no longer creates sessions without an OSCAR instance, so a
  143. // nil here is a server-side invariant violation, not a bad request.
  144. if session.OSCARSession == nil {
  145. m.sendSessionError(w, http.StatusInternalServerError, "internal server error")
  146. return
  147. }
  148. _ = sm.TouchSession(r.Context(), aimsid)
  149. next(w, r, session)
  150. })
  151. }
  152. // sendSessionError writes a Web AIM API error envelope with the given HTTP status.
  153. func (m *AuthMiddleware) sendSessionError(w http.ResponseWriter, statusCode int, message string) {
  154. body, err := json.Marshal(map[string]any{
  155. "response": map[string]any{"statusCode": statusCode, "statusText": message},
  156. })
  157. if err != nil {
  158. m.Logger.Error("failed to encode error response", "err", err.Error())
  159. http.Error(w, "internal server error", http.StatusInternalServerError)
  160. return
  161. }
  162. w.Header().Set("Content-Type", "application/json")
  163. w.WriteHeader(statusCode)
  164. _, _ = w.Write(body)
  165. }
  166. // Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
  167. func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
  168. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  169. // Extract API key from 'k' parameter (query or form)
  170. apiKey := r.URL.Query().Get("k")
  171. if apiKey == "" {
  172. // Try form value for POST requests
  173. apiKey = r.FormValue("k")
  174. }
  175. if apiKey == "" {
  176. m.sendErrorResponse(w, r, http.StatusBadRequest, "required parameter 'k' is missing")
  177. return
  178. }
  179. // Validate API key
  180. ctx := r.Context()
  181. key := m.resolveAPIKey(ctx, apiKey)
  182. if key == nil {
  183. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  184. m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
  185. return
  186. }
  187. // Check rate limit
  188. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  189. // Always add rate limit headers
  190. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  191. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  192. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  193. if !rateLimitInfo.Allowed {
  194. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  195. // Add Retry-After header
  196. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  197. if retryAfter < 1 {
  198. retryAfter = 1
  199. }
  200. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  201. m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
  202. return
  203. }
  204. // Update last used timestamp asynchronously
  205. go func() {
  206. if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
  207. m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
  208. }
  209. }()
  210. // Add API key info to context for use in handlers
  211. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  212. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  213. // Log the API request
  214. m.Logger.InfoContext(ctx, "API request authenticated",
  215. "dev_id", key.DevID,
  216. "app_name", key.AppName,
  217. "method", r.Method,
  218. "path", r.URL.Path,
  219. )
  220. // Pass to next handler with enriched context
  221. next.ServeHTTP(w, r.WithContext(ctx))
  222. })
  223. }
  224. // CORSMiddleware handles CORS headers based on allowed origins for the API key.
  225. func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
  226. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  227. // Get API key from context (set by Authenticate middleware)
  228. key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
  229. // If no API key in context (e.g., using aimsid auth), allow all origins
  230. // This is safe because the actual authentication is handled by the session
  231. var allowedOrigins []string
  232. if ok && key != nil {
  233. allowedOrigins = key.AllowedOrigins
  234. } else {
  235. // For session-based auth without API key, allow all origins
  236. // The session itself provides the security boundary
  237. m.Logger.DebugContext(r.Context(), "CORS handling for non-API-key auth (aimsid/token)")
  238. allowedOrigins = []string{"*"}
  239. }
  240. origin := r.Header.Get("Origin")
  241. // Check if origin is allowed
  242. if m.isOriginAllowed(origin, allowedOrigins) {
  243. if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
  244. // For wildcard, set the actual origin to allow credentials
  245. if origin != "" {
  246. w.Header().Set("Access-Control-Allow-Origin", origin)
  247. } else {
  248. w.Header().Set("Access-Control-Allow-Origin", "*")
  249. }
  250. } else {
  251. w.Header().Set("Access-Control-Allow-Origin", origin)
  252. }
  253. w.Header().Set("Access-Control-Allow-Credentials", "true")
  254. w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
  255. w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
  256. w.Header().Set("Access-Control-Max-Age", "3600")
  257. }
  258. // Handle preflight requests
  259. if r.Method == "OPTIONS" {
  260. w.WriteHeader(http.StatusNoContent)
  261. return
  262. }
  263. next.ServeHTTP(w, r)
  264. })
  265. }
  266. // isOriginAllowed checks if an origin is in the allowed list.
  267. func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
  268. // If no origins specified, allow all (for backward compatibility/development)
  269. if len(allowedOrigins) == 0 {
  270. return true
  271. }
  272. origin = strings.ToLower(origin)
  273. for _, allowed := range allowedOrigins {
  274. allowed = strings.ToLower(allowed)
  275. // Exact match
  276. if origin == allowed {
  277. return true
  278. }
  279. // Wildcard support (e.g., "*.example.com")
  280. if strings.HasPrefix(allowed, "*.") {
  281. domain := allowed[2:]
  282. if strings.HasSuffix(origin, domain) {
  283. return true
  284. }
  285. }
  286. // Allow all origins (development only)
  287. if allowed == "*" {
  288. m.Logger.Warn("wildcard origin (*) used - should not be used in production")
  289. return true
  290. }
  291. }
  292. return false
  293. }
  294. // sendErrorResponse sends a Web AIM API error envelope, with JSONP support when requested.
  295. func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  296. response := map[string]interface{}{
  297. "response": map[string]interface{}{
  298. "statusCode": statusCode,
  299. "statusText": message,
  300. },
  301. }
  302. body, err := json.Marshal(response)
  303. if err != nil {
  304. m.Logger.Error("failed to encode error response", "err", err.Error())
  305. http.Error(w, "internal server error", http.StatusInternalServerError)
  306. return
  307. }
  308. callback := jsonpCallback(r)
  309. if callback != "" && isValidJSONPCallback(callback) {
  310. w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
  311. _, _ = w.Write([]byte(callback))
  312. _, _ = w.Write([]byte("("))
  313. _, _ = w.Write(body)
  314. _, _ = w.Write([]byte(");"))
  315. return
  316. }
  317. w.Header().Set("Content-Type", "application/json")
  318. _, _ = w.Write(body)
  319. }
  320. func jsonpCallback(r *http.Request) string {
  321. if callback := r.URL.Query().Get("c"); callback != "" {
  322. return callback
  323. }
  324. return r.URL.Query().Get("callback")
  325. }
  326. func isValidJSONPCallback(callback string) bool {
  327. if len(callback) == 0 || len(callback) > 100 {
  328. return false
  329. }
  330. for _, r := range callback {
  331. if (r < 'a' || r > 'z') &&
  332. (r < 'A' || r > 'Z') &&
  333. (r < '0' || r > '9') &&
  334. r != '_' && r != '$' && r != '.' {
  335. return false
  336. }
  337. }
  338. return true
  339. }
  340. // min returns the minimum of two integers.
  341. func min(a, b int) int {
  342. if a < b {
  343. return a
  344. }
  345. return b
  346. }
  347. // AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
  348. // 1. aimsid (session ID) - no k required
  349. // 2. a (AOL token) - no k required
  350. // 3. ts + sig_sha256 (signed request) - no k required
  351. // 4. k (API key) - fallback if no other auth provided
  352. // This follows the Web AIM API specification where k is not required when aimsid is present.
  353. func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
  354. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  355. ctx := r.Context()
  356. // Priority 1: Check for session-based auth (aimsid)
  357. // According to the spec, when aimsid is provided, k is not required
  358. if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
  359. // The handler itself will validate the aimsid
  360. // We just need to pass the request through without requiring k
  361. m.Logger.DebugContext(ctx, "using aimsid authentication", "aimsid", aimsid[:min(16, len(aimsid))]+"...")
  362. next.ServeHTTP(w, r)
  363. return
  364. }
  365. // Priority 2: AOL token auth — user identity is in the token; k is optional.
  366. if token := r.URL.Query().Get("a"); token != "" {
  367. key := m.resolveAPIKey(ctx, r.URL.Query().Get("k"))
  368. if key == nil {
  369. devKey := r.URL.Query().Get("k")
  370. key = &state.WebAPIKey{
  371. DevID: "aim_web",
  372. DevKey: devKey,
  373. AppName: "AIM Web Client",
  374. IsActive: true,
  375. RateLimit: 600,
  376. }
  377. }
  378. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  379. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  380. m.Logger.DebugContext(ctx, "using token authentication", "dev_id", key.DevID)
  381. next.ServeHTTP(w, r.WithContext(ctx))
  382. return
  383. }
  384. // Priority 3: Check for signed request auth
  385. if ts := r.URL.Query().Get("ts"); ts != "" {
  386. if sig := r.URL.Query().Get("sig_sha256"); sig != "" {
  387. // For now, signed requests still require 'k' parameter for API key validation
  388. // The signature provides additional security on top of the API key
  389. // When full signature validation is implemented, this can be made optional
  390. m.Logger.DebugContext(ctx, "signed request detected, falling through to API key validation")
  391. // Don't return here - continue to API key validation below
  392. }
  393. }
  394. // Priority 4: Fall back to API key requirement
  395. apiKey := r.URL.Query().Get("k")
  396. if apiKey == "" {
  397. // Try form value for POST requests
  398. apiKey = r.FormValue("k")
  399. }
  400. if apiKey == "" {
  401. m.sendErrorResponse(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
  402. return
  403. }
  404. key := m.resolveAPIKey(ctx, apiKey)
  405. if key == nil {
  406. m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
  407. m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
  408. return
  409. }
  410. // Check rate limit
  411. rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
  412. // Always add rate limit headers
  413. w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
  414. w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
  415. w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
  416. if !rateLimitInfo.Allowed {
  417. m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
  418. // Add Retry-After header
  419. retryAfter := rateLimitInfo.Reset - time.Now().Unix()
  420. if retryAfter < 1 {
  421. retryAfter = 1
  422. }
  423. w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
  424. m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
  425. return
  426. }
  427. // Update last used timestamp asynchronously
  428. go func() {
  429. if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
  430. m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
  431. }
  432. }()
  433. // Add API key info to context for use in handlers
  434. ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
  435. ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
  436. // Log the API request
  437. m.Logger.InfoContext(ctx, "API request authenticated via key",
  438. "dev_id", key.DevID,
  439. "app_name", key.AppName,
  440. "method", r.Method,
  441. "path", r.URL.Path,
  442. )
  443. // Pass to next handler with enriched context
  444. next.ServeHTTP(w, r.WithContext(ctx))
  445. })
  446. }
  447. func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *state.WebAPIKey {
  448. if devKey == "" {
  449. return nil
  450. }
  451. key, err := m.Validator.GetAPIKeyByDevKey(ctx, devKey)
  452. if err != nil || key == nil || !key.IsActive {
  453. return nil
  454. }
  455. return key
  456. }