Coverage for gws-app/gws/base/auth/throttle.py: 99%
91 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""Authentication throttle.
3Counts failed authentication attempts and blocks further attempts once a limit is reached.
4Attempts are counted per remote address and, optionally, per login name.
5"""
7from typing import Optional
9import gws
10import gws.lib.datetimex as dtx
11import gws.lib.sqlitex
14class Config(gws.Config):
15 """Authentication throttle options. (added in 8.4)"""
17 maxAttemptsPerIp: int = 10
18 """Failed attempts from one address before blocking."""
19 maxAttemptsPerUser: int = 0
20 """Failed attempts for one login from all addresses before blocking, 0=no limit."""
21 windowTime: gws.Duration = '10m'
22 """Time span in which failed attempts are counted."""
23 blockTime: gws.Duration = '15m'
24 """How long to block once the limit is reached."""
25 allowFrom: Optional[list[str]]
26 """Addresses exempt from throttling."""
27 path: Optional[str]
28 """Throttle storage path."""
31_CLEANUP_INTERVAL = 600
32_MAX_NAME_LENGTH = 128
35class Object(gws.Node):
36 """Authentication throttle."""
38 maxAttemptsPerIp: int
39 maxAttemptsPerUser: int
40 windowTime: int
41 blockTime: int
42 allowFrom: set[str]
43 dbPath: str
45 table = 'throttle'
47 def configure(self):
48 self.maxAttemptsPerIp = self.cfg('maxAttemptsPerIp', default=10)
49 self.maxAttemptsPerUser = self.cfg('maxAttemptsPerUser', default=0)
50 self.windowTime = self.cfg('windowTime', default=dtx.parse_duration(Config.windowTime))
51 self.blockTime = self.cfg('blockTime', default=dtx.parse_duration(Config.blockTime))
52 self.allowFrom = set(self.cfg('allowFrom') or [])
53 self.dbPath = self.cfg('path', default=f'{gws.c.MISC_DIR}/auth_throttle.sqlite')
55 if self.blockTime <= self.windowTime:
56 raise gws.ConfigurationError(f'invalid blockTime={self.blockTime}, must be greater than windowTime={self.windowTime}')
58 ##
60 def blocked_for(self, req: gws.WebRequester, method: gws.AuthMethod, credentials: gws.Data) -> int:
61 """Return the time in seconds the given attempt remains blocked, 0 if it is allowed."""
63 u_addr, u_user = self._get_uids(req, credentials)
64 if not u_addr and not u_user:
65 return 0
67 now = gws.u.stime()
68 rs = self._db().select(
69 f'SELECT MAX(blocked_until) AS t FROM {self.table} WHERE uid IN (:u_addr, :u_user)',
70 u_addr=u_addr,
71 u_user=u_user,
72 )
74 t = rs[0]['t'] if rs else None
75 return max(0, (t or 0) - now)
77 def register(self, ok: bool, req: gws.WebRequester, method: gws.AuthMethod, credentials: gws.Data):
78 """Register the outcome of an authentication attempt."""
80 u_addr, u_user = self._get_uids(req, credentials)
81 if not u_addr and not u_user:
82 return
84 if ok:
85 self._db().execute(
86 f'DELETE FROM {self.table} WHERE uid IN (:u_addr, :u_user)',
87 u_addr=u_addr,
88 u_user=u_user,
89 )
90 return
92 if gws.u.stime() > self._cleanupTime + _CLEANUP_INTERVAL:
93 self.cleanup()
95 if u_addr:
96 self._add_failure(u_addr, self.maxAttemptsPerIp)
97 if u_user:
98 self._add_failure(u_user, self.maxAttemptsPerUser)
100 _cleanupTime = 0
102 def cleanup(self):
103 # a row may only be dropped when its window has elapsed *and* it holds no live block,
104 # the row is the only place a block is recorded
106 now = gws.u.stime()
107 self._db().execute(
108 f'DELETE FROM {self.table} WHERE first_time < :window_start AND blocked_until <= :now',
109 window_start=now - self.windowTime,
110 now=now,
111 )
112 self._cleanupTime = now
114 ##
116 def _add_failure(self, uid: str, max_attempts: int):
117 now = gws.u.stime()
119 # a new attempt starts a new window if the current one has elapsed.
120 # an expired block needs no test of its own: since blockTime is greater than windowTime,
121 # the window has always elapsed by the time a block runs out
123 expired = 'first_time < :window_start'
125 self._db().execute(
126 f"""
127 INSERT INTO {self.table} (uid, attempts, first_time, blocked_until)
128 VALUES (:uid, 1, :now, 0)
129 ON CONFLICT (uid) DO UPDATE SET
130 attempts = CASE WHEN {expired} THEN 1 ELSE attempts + 1 END,
131 first_time = CASE WHEN {expired} THEN :now ELSE first_time END,
132 blocked_until = 0
133 """,
134 uid=uid,
135 now=now,
136 window_start=now - self.windowTime,
137 )
139 self._db().execute(
140 f"""
141 UPDATE {self.table} SET blocked_until = :until
142 WHERE uid = :uid AND attempts >= :max_attempts
143 """,
144 uid=uid,
145 until=now + self.blockTime,
146 max_attempts=max_attempts,
147 )
149 def _get_uids(self, req: gws.WebRequester, credentials: gws.Data) -> tuple[str, str]:
150 ip = req.ip
151 if ip and ip in self.allowFrom:
152 return '', ''
154 u_addr = ''
155 if ip and self.maxAttemptsPerIp > 0:
156 u_addr = f'ip:{ip}'
158 u_user = ''
159 if self.maxAttemptsPerUser > 0:
160 name = self._login_name(credentials)
161 if name:
162 u_user = f'user:{gws.u.sha256(name)}'
164 return u_addr, u_user
166 def _login_name(self, credentials: gws.Data) -> str:
167 s = credentials.get('username')
168 if not isinstance(s, str):
169 return ''
170 return s.strip().casefold()[:_MAX_NAME_LENGTH]
172 ##
174 _sqlitex: gws.lib.sqlitex.Object
176 def _db(self):
177 if getattr(self, '_sqlitex', None) is None:
178 ddl = f"""
179 CREATE TABLE IF NOT EXISTS {self.table} (
180 uid TEXT NOT NULL PRIMARY KEY,
181 attempts INTEGER NOT NULL,
182 first_time INTEGER NOT NULL,
183 blocked_until INTEGER NOT NULL
184 )
185 """
186 self._sqlitex = gws.lib.sqlitex.Object(self.dbPath, ddl)
187 return self._sqlitex