Coverage for gws-app/gws/base/auth/throttle.py: 99%
89 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Authentication throttle."""
3from typing import Optional
5import gws
6import gws.lib.datetimex as dtx
7import gws.lib.sqlitex
10class Config(gws.Config):
11 """Blocking of repeated failed authentication attempts."""
13 maxAttemptsPerIp: int = 10
14 """Failed attempts from one address before blocking."""
15 maxAttemptsPerUser: int = 0
16 """Failed attempts per login name from any address before blocking, 0 for no limit."""
17 windowTime: gws.Duration = '10m'
18 """Time span in which failed attempts are counted."""
19 blockTime: gws.Duration = '15m'
20 """How long to block once the limit is reached."""
21 allowFrom: Optional[list[str]]
22 """IP addresses exempt from throttling."""
23 path: Optional[str]
24 """File that stores failed attempts."""
27_CLEANUP_INTERVAL = 600
28_MAX_NAME_LENGTH = 128
31class Object(gws.Node):
32 """Authentication throttle.
34 Counts failed authentication attempts per remote address and, optionally,
35 per login name, and blocks further attempts once a limit is reached.
36 """
38 maxAttemptsPerIp: int
39 """Failed attempts from one address before blocking, 0 for no limit."""
40 maxAttemptsPerUser: int
41 """Failed attempts per login name before blocking, 0 for no limit."""
42 windowTime: int
43 """Time span in seconds in which failed attempts are counted."""
44 blockTime: int
45 """Blocking time in seconds."""
46 allowFrom: set[str]
47 """IP addresses exempt from throttling."""
48 dbPath: str
49 """Path to the sqlite database."""
51 table = 'throttle'
52 """Name of the database table."""
54 def configure(self):
55 self.maxAttemptsPerIp = self.cfg('maxAttemptsPerIp', default=10)
56 self.maxAttemptsPerUser = self.cfg('maxAttemptsPerUser', default=0)
57 self.windowTime = self.cfg('windowTime', default=dtx.parse_duration(Config.windowTime))
58 self.blockTime = self.cfg('blockTime', default=dtx.parse_duration(Config.blockTime))
59 self.allowFrom = set(self.cfg('allowFrom') or [])
60 self.dbPath = self.cfg('path', default=f'{gws.c.MISC_DIR}/auth_throttle.sqlite')
62 if self.blockTime <= self.windowTime:
63 raise gws.ConfigurationError(f'invalid blockTime={self.blockTime}, must be greater than windowTime={self.windowTime}')
65 ##
67 def blocked_for(self, req: gws.WebRequester, method: gws.AuthMethod, credentials: gws.Data) -> int:
68 """Return how long an authentication attempt remains blocked.
70 Args:
71 req: The web request, provides the remote address.
72 method: The authentication method.
73 credentials: The credentials, provide the login name as ``username``.
75 Returns:
76 The remaining blocking time in seconds, 0 if the attempt is allowed.
77 """
79 u_addr, u_user = self._get_uids(req, credentials)
80 if not u_addr and not u_user:
81 return 0
83 now = gws.u.stime()
84 rs = self._db().select(
85 f'SELECT MAX(blocked_until) AS t FROM {self.table} WHERE uid IN (:u_addr, :u_user)',
86 u_addr=u_addr,
87 u_user=u_user,
88 )
90 t = rs[0]['t'] if rs else None
91 return max(0, (t or 0) - now)
93 def register(self, ok: bool, req: gws.WebRequester, method: gws.AuthMethod, credentials: gws.Data):
94 """Register the outcome of an authentication attempt.
96 A success removes the counts for the address and the login name.
97 A failure increments them and starts a block once a limit is reached.
99 Args:
100 ok: Whether the attempt was successful.
101 req: The web request, provides the remote address.
102 method: The authentication method.
103 credentials: The credentials, provide the login name as ``username``.
104 """
106 u_addr, u_user = self._get_uids(req, credentials)
107 if not u_addr and not u_user:
108 return
110 if ok:
111 self._db().execute(
112 f'DELETE FROM {self.table} WHERE uid IN (:u_addr, :u_user)',
113 u_addr=u_addr,
114 u_user=u_user,
115 )
116 return
118 if gws.u.stime() > self._cleanupTime + _CLEANUP_INTERVAL:
119 self.cleanup()
121 if u_addr:
122 self._add_failure(u_addr, self.maxAttemptsPerIp)
123 if u_user:
124 self._add_failure(u_user, self.maxAttemptsPerUser)
126 _cleanupTime = 0
128 def cleanup(self):
129 """Remove the counts whose window has elapsed and which hold no active block."""
131 # a row may only be dropped when its window has elapsed *and* it holds no live block,
132 # the row is the only place a block is recorded
134 now = gws.u.stime()
135 self._db().execute(
136 f'DELETE FROM {self.table} WHERE first_time < :window_start AND blocked_until <= :now',
137 window_start=now - self.windowTime,
138 now=now,
139 )
140 self._cleanupTime = now
142 ##
144 def _add_failure(self, uid: str, max_attempts: int):
145 """Increment the failure count of a key, starting a new window if needed, and block if the limit is reached."""
146 now = gws.u.stime()
148 # a new attempt starts a new window if the current one has elapsed.
149 # an expired block needs no test of its own: since blockTime is greater than windowTime,
150 # the window has always elapsed by the time a block runs out
152 expired = 'first_time < :window_start'
154 self._db().execute(
155 f"""
156 INSERT INTO {self.table} (uid, attempts, first_time, blocked_until)
157 VALUES (:uid, 1, :now, 0)
158 ON CONFLICT (uid) DO UPDATE SET
159 attempts = CASE WHEN {expired} THEN 1 ELSE attempts + 1 END,
160 first_time = CASE WHEN {expired} THEN :now ELSE first_time END,
161 blocked_until = 0
162 """,
163 uid=uid,
164 now=now,
165 window_start=now - self.windowTime,
166 )
168 self._db().execute(
169 f"""
170 UPDATE {self.table} SET blocked_until = :until
171 WHERE uid = :uid AND attempts >= :max_attempts
172 """,
173 uid=uid,
174 until=now + self.blockTime,
175 max_attempts=max_attempts,
176 )
178 def _get_uids(self, req: gws.WebRequester, credentials: gws.Data) -> tuple[str, str]:
179 """Return the address and login name keys of an attempt, empty if not counted."""
180 ip = req.ip
181 if ip and ip in self.allowFrom:
182 return '', ''
184 u_addr = ''
185 if ip and self.maxAttemptsPerIp > 0:
186 u_addr = f'ip:{ip}'
188 u_user = ''
189 if self.maxAttemptsPerUser > 0:
190 name = self._login_name(credentials)
191 if name:
192 u_user = f'user:{gws.u.sha256(name)}'
194 return u_addr, u_user
196 def _login_name(self, credentials: gws.Data) -> str:
197 """Return the normalized login name from the credentials."""
198 s = credentials.get('username')
199 if not isinstance(s, str):
200 return ''
201 return s.strip().casefold()[:_MAX_NAME_LENGTH]
203 ##
205 _sqlitex: gws.lib.sqlitex.Object
207 def _db(self):
208 """Return the sqlite database, creating the table on first use."""
209 if getattr(self, '_sqlitex', None) is None:
210 ddl = f"""
211 CREATE TABLE IF NOT EXISTS {self.table} (
212 uid TEXT NOT NULL PRIMARY KEY,
213 attempts INTEGER NOT NULL,
214 first_time INTEGER NOT NULL,
215 blocked_until INTEGER NOT NULL
216 )
217 """
218 self._sqlitex = gws.lib.sqlitex.Object(self.dbPath, ddl)
219 return self._sqlitex