Coverage for src/lexigram/auth/web/middleware/throttle.py: 79%

77 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Rate-limiting middleware for authentication endpoints. 

2 

3Provides in-memory IP-based rate limiting targeted at brute-force-prone 

4auth endpoints (``/auth/login``, ``/auth/register``, etc.). 

5 

6Typical usage:: 

7 

8 app = RateLimitMiddleware(app, rate_limit="5/minute", block_duration=300) 

9""" 

10 

11from __future__ import annotations 

12 

13from collections import defaultdict 

14from typing import TYPE_CHECKING, Any 

15 

16from lexigram import serialization as _json 

17from lexigram.primitives import clock as ambient_clock 

18 

19if TYPE_CHECKING: 

20 from collections.abc import Callable 

21 

22# Endpoints to which rate limiting is applied. 

23_RATE_LIMITED_PATHS: frozenset[str] = frozenset( 

24 { 

25 "/auth/login", 

26 "/auth/register", 

27 "/auth/token", 

28 "/auth/refresh", 

29 "/auth/password-reset", 

30 } 

31) 

32 

33 

34class RateLimitExceededError(Exception): 

35 """Raised when a client has exceeded the configured rate limit. 

36 

37 Carries ``retry_after`` (seconds) so callers can propagate the value in 

38 a ``Retry-After`` response header. 

39 """ 

40 

41 _code = "LEX_ERR_SEC_012" 

42 

43 def __init__(self, retry_after: int) -> None: 

44 super().__init__(f"Rate limit exceeded. Retry after {retry_after}s.") 

45 self.retry_after = retry_after 

46 

47 

48def _parse_rate_limit(rate_limit: str) -> tuple[int, int]: 

49 """Parse a rate-limit string such as ``"5/minute"`` into ``(max, seconds)``. 

50 

51 Supported periods: ``second`` / ``seconds``, ``minute`` / ``minutes``, 

52 ``hour`` / ``hours``. 

53 

54 Args: 

55 rate_limit: A string in the form ``"<count>/<period>"``. 

56 

57 Returns: 

58 A ``(max_requests, window_seconds)`` tuple. 

59 

60 Raises: 

61 ValueError: If the string cannot be parsed. 

62 """ 

63 try: 

64 count_str, period = rate_limit.split("/", maxsplit=1) 

65 count = int(count_str.strip()) 

66 period = period.strip().lower() 

67 except (ValueError, AttributeError) as exc: 

68 raise ValueError( 

69 f"Invalid rate_limit format {rate_limit!r}. " 

70 "Expected '<count>/<period>', e.g. '5/minute'." 

71 ) from exc 

72 

73 _period_map: dict[str, int] = { 

74 "second": 1, 

75 "seconds": 1, 

76 "minute": 60, 

77 "minutes": 60, 

78 "hour": 3600, 

79 "hours": 3600, 

80 } 

81 if period not in _period_map: 

82 raise ValueError( 

83 f"Unknown period {period!r}. " 

84 f"Supported values: {', '.join(sorted(_period_map))}." 

85 ) 

86 return count, _period_map[period] 

87 

88 

89class RateLimitMiddleware: 

90 """ASGI middleware that rate-limits authentication endpoints. 

91 

92 Applies a sliding-window rate limit to requests whose ``path`` matches 

93 a known auth endpoint. Clients that exceed the limit receive a ``429`` 

94 response and are blocked for *block_duration* seconds. Successful 

95 responses (HTTP 2xx) clear the attempt history for the client. 

96 

97 Args: 

98 app: The next ASGI application in the chain. 

99 rate_limit: Rate-limit string, e.g. ``"5/minute"``. 

100 block_duration: How long (seconds) a client remains blocked after 

101 exceeding the limit. Defaults to ``60``. 

102 paths: Optional set of URL paths to which rate limiting is applied. 

103 Falls back to the built-in :data:`_RATE_LIMITED_PATHS` set. 

104 """ 

105 

106 def __init__( 

107 self, 

108 app: Any, 

109 rate_limit: str = "5/minute", 

110 block_duration: int = 60, 

111 paths: frozenset[str] | None = None, 

112 cache_service: Any | None = None, 

113 ) -> None: 

114 self.app = app 

115 self.block_duration = block_duration 

116 self._paths = paths if paths is not None else _RATE_LIMITED_PATHS 

117 # Optional external cache (e.g. Redis) for distributed rate limiting. 

118 # Not used in the built-in in-memory implementation; reserved for 

119 # future Redis-backed backends. 

120 self.cache_service = cache_service 

121 

122 max_requests, window_seconds = _parse_rate_limit(rate_limit) 

123 self._max_requests = max_requests 

124 self._window_seconds = window_seconds 

125 

126 # Mutable state exposed for tests 

127 self.attempts: dict[str, list[float]] = defaultdict(list) 

128 self.blocked: dict[str, float] = {} 

129 

130 # ------------------------------------------------------------------ 

131 # ASGI interface 

132 # ------------------------------------------------------------------ 

133 

134 async def __call__( 

135 self, 

136 scope: dict[str, Any], 

137 receive: Any, 

138 send: Any, 

139 ) -> None: 

140 """Process an ASGI request.""" 

141 if scope.get("type") != "http": 

142 await self.app(scope, receive, send) 

143 return 

144 

145 path: str = scope.get("path", "") 

146 if path not in self._paths: 

147 await self.app(scope, receive, send) 

148 return 

149 

150 client = scope.get("client") 

151 client_id: str = client[0] if client else "unknown" 

152 

153 # --- get current time --- 

154 now = ambient_clock.monotonic() 

155 

156 # --- check / enforce block --- 

157 if client_id in self.blocked: 

158 unblock_at = self.blocked[client_id] 

159 remaining = int(unblock_at - now) 

160 if remaining > 0: 

161 await self._send_429(send, remaining) 

162 return 

163 # Block expired — release 

164 del self.blocked[client_id] 

165 

166 # --- sliding-window check --- 

167 cutoff = now - self._window_seconds 

168 self.attempts[client_id] = [t for t in self.attempts[client_id] if t > cutoff] 

169 if len(self.attempts[client_id]) >= self._max_requests: 

170 # Block the client and respond 429 

171 self.blocked[client_id] = now + self.block_duration 

172 await self._send_429(send, self.block_duration) 

173 return 

174 

175 # --- record this attempt and delegate to next app --- 

176 self.attempts[client_id].append(now) 

177 

178 # Wrap send so we can inspect the response status code 

179 response_status: list[int] = [] 

180 

181 async def intercepting_send(message: dict[str, Any]) -> None: 

182 if message.get("type") == "http.response.start": 

183 response_status.append(message.get("status", 0)) 

184 await send(message) 

185 

186 await self.app(scope, receive, intercepting_send) 

187 

188 # On a successful response, clear the attempt counter 

189 if response_status and 200 <= response_status[0] < 300: 

190 self.attempts[client_id] = [] 

191 

192 # ------------------------------------------------------------------ 

193 # Helpers 

194 # ------------------------------------------------------------------ 

195 

196 @staticmethod 

197 async def _send_429(send: Any, retry_after: int) -> None: 

198 """Send a 429 Too Many Requests ASGI response.""" 

199 body = _json.dumps( 

200 { 

201 "detail": "Too many requests. Please try again later.", 

202 "retry_after": retry_after, 

203 } 

204 ) 

205 retry_bytes = str(retry_after).encode() 

206 await send( 

207 { 

208 "type": "http.response.start", 

209 "status": 429, 

210 "headers": [ 

211 (b"content-type", b"application/json"), 

212 (b"retry-after", retry_bytes), 

213 ], 

214 } 

215 ) 

216 await send({"type": "http.response.body", "body": body, "more_body": False}) 

217 

218 

219def throttle( 

220 rate_limit: str = "5/minute", 

221 block_duration: int = 60, 

222 paths: frozenset[str] | None = None, 

223) -> Callable[[Any], RateLimitMiddleware]: 

224 """Decorator / factory that wraps an ASGI app with :class:`RateLimitMiddleware`. 

225 

226 Can be used as a decorator:: 

227 

228 @throttle(rate_limit="10/minute") 

229 async def app(scope, receive, send): ... 

230 

231 Args: 

232 rate_limit: Rate-limit string, e.g. ``"5/minute"``. 

233 block_duration: Block duration in seconds. 

234 paths: URL paths to rate-limit (defaults to built-in auth paths). 

235 

236 Returns: 

237 A callable that wraps an ASGI app with the configured middleware. 

238 """ 

239 

240 def decorator(app: Any) -> RateLimitMiddleware: 

241 return RateLimitMiddleware( 

242 app, 

243 rate_limit=rate_limit, 

244 block_duration=block_duration, 

245 paths=paths, 

246 ) 

247 

248 return decorator 

249 

250 

251__all__ = [ 

252 "RateLimitExceededError", 

253 "RateLimitMiddleware", 

254 "throttle", 

255]