Coverage for gws-app/gws/plugin/auth_method/web/core.py: 78%
196 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"""The ``web`` authentication method and its API types."""
3import re
4from typing import Optional, cast
6import gws
7import gws.base.auth
8import gws.base.web
11class LoginRedirectRule(gws.Data):
12 """Redirect to a login page for page requests that are denied."""
14 pattern: Optional[gws.Regex]
15 """Regular expression for URLs to redirect."""
16 target: str
17 """Login page URL."""
20@gws.ext.config.authMethod('web')
21class Config(gws.base.auth.method.Config):
22 """Authentication with a login form and a session cookie."""
24 cookieName: str = 'auth'
25 """Name of the session cookie."""
26 cookiePath: str = '/'
27 """Path attribute of the session cookie."""
28 cookieSameSite: str = 'Lax'
29 """SameSite attribute of the session cookie."""
30 loginRedirect: Optional[LoginRedirectRule]
31 """Redirect denied page requests to a login page."""
34##
37class UserResponse(gws.Response):
38 """Response with the current user."""
40 user: Optional[gws.base.auth.user.Props]
41 """Properties of the user, ``None`` for guests."""
44class LogoutResponse(gws.Response):
45 """Response to a logout request."""
47 pass
50class LoginRequest(gws.Request):
51 """Login request."""
53 username: str
54 """Login name."""
55 password: str
56 """Password."""
57 to: Optional[str]
58 """URL path to return to after the login."""
61class LoginResponse(gws.Response):
62 """Response to a login or multi-factor request."""
64 user: Optional[gws.base.auth.user.Props]
65 """Properties of the logged-in user, set when the login is completed without a second step."""
66 mfaState: Optional[gws.AuthMultiFactorState]
67 """State of the multi-factor transaction, if there is one."""
68 mfaMessage: str = ''
69 """Message for the user in the multi-factor step."""
70 mfaCanRestart: bool = False
71 """The multi-factor transaction can be restarted."""
72 redirectTo: str = ''
73 """URL path to go to after a completed login, empty if none."""
76class MfaVerifyRequest(gws.Request):
77 """Request to verify a multi-factor code."""
79 payload: dict
80 """Data entered by the user, for example ``{"code": "123456"}``."""
81 to: Optional[str]
82 """URL path to return to after the login."""
85##
87_DELETED_SESSION = 'web:deleted'
90@gws.ext.object.authMethod('web')
91class Object(gws.base.auth.method.Object):
92 """Web authentication method.
94 Authenticates users with a login form and keeps their sessions in the
95 session manager, identified by a session cookie. Handles logins, logouts
96 and multi-factor verification for the ``auth`` action.
97 """
99 cookieName: str
100 """Name of the session cookie."""
101 cookiePath: str
102 """Path attribute of the session cookie."""
103 cookieSameSite: str
104 """SameSite attribute of the session cookie."""
105 loginRedirect: Optional[LoginRedirectRule]
106 """Redirect rule for denied page requests, or ``None``."""
108 deletedSession: gws.base.auth.session.Object
109 """Placeholder session for requests with an invalid session cookie."""
111 def configure(self):
112 self.uid = 'gws.plugin.auth.method.web'
113 self.cookieName = self.cfg('cookieName', default=Config.cookieName)
114 self.cookiePath = self.cfg('cookiePath', default=Config.cookiePath)
115 self.cookieSameSite = self.cfg('cookieSameSite', default=Config.cookieSameSite)
116 self.loginRedirect = self.cfg('loginRedirect')
117 self.root.app.middlewareMgr.register(self, self.uid, depends_on=['auth'])
119 ##
121 def exit_middleware(self, req, res):
122 if res.status in (403, 401) and req.isGet:
123 self._check_login_redirect(req, res)
125 def _check_login_redirect(self, req: gws.WebRequester, res: gws.WebResponder):
126 """Redirect a denied page request to the login page, if configured."""
127 lr = self.loginRedirect
128 if not lr:
129 return
130 request_uri = req.env('REQUEST_URI', '')
131 if not request_uri:
132 return
133 if lr.pattern and not re.match(lr.pattern, request_uri):
134 return
135 redir = req.relative_url_for(lr.target, to=request_uri)
136 res.set_status(302)
137 res.add_header('Location', redir)
138 res.set_body(f'Redirecting to {redir}...')
139 gws.log.debug(f'auth web: redirect {res.status=} {redir=}')
141 def activate(self):
142 am = self.root.app.authMgr
143 self.deletedSession = gws.base.auth.session.Object(
144 uid=_DELETED_SESSION,
145 method=self,
146 user=am.guestUser,
147 )
149 def open_session(self, req):
150 am = self.root.app.authMgr
152 sid = req.cookie(self.cookieName)
153 if not sid:
154 return
156 sess = am.sessionMgr.get(sid)
158 if not sess:
159 gws.log.debug(f'open_session: {sid=} not found or invalid')
160 return self.deletedSession
162 return sess
164 def close_session(self, req, res):
165 am = self.root.app.authMgr
167 sess = getattr(req, 'session')
168 if not sess:
169 return
171 if sess.uid == _DELETED_SESSION:
172 gws.log.debug('session cookie=deleted')
173 res.delete_cookie(
174 self.cookieName,
175 path=self.cookiePath,
176 )
177 return
179 if res.status < 400:
180 gws.log.debug(f'session cookie={sess.uid!r}')
181 res.set_cookie(
182 self.cookieName,
183 sess.uid,
184 path=self.cookiePath,
185 secure=self.secure,
186 samesite=self.cookieSameSite,
187 httponly=True,
188 )
189 am.sessionMgr.touch(sess)
191 def handle_login(self, req: gws.WebRequester, p: LoginRequest) -> LoginResponse:
192 """Log in with a username and a password.
194 The credentials are checked by the authentication providers. If the
195 user has an ``mfaUid``, a multi-factor transaction is started and kept
196 in a new guest session; ``handle_mfa_verify`` completes the login.
197 Otherwise the current session is replaced by a new session for the user.
199 Args:
200 req: Web requester.
201 p: Login request.
203 Returns:
204 The user and the redirect target, or the multi-factor state.
206 Raises:
207 ``gws.ForbiddenError``: If the user is already logged in, the method is secure and the request is not, or the multi-factor transaction cannot be started.
208 ``gws.AuthenticationError``: If the credentials are not accepted.
209 """
210 if not req.user.isGuest:
211 raise gws.ForbiddenError(f'login: already logged-in {req.user.uid=}')
213 if self.secure and not req.isSecure:
214 raise gws.ForbiddenError('login: insecure_context, ignored')
216 user = self.root.app.authMgr.authenticate(self, p, req)
217 if not user:
218 raise gws.AuthenticationError('login: user not found')
220 if user.mfaUid:
221 mfa = self._mfa_start(req, user)
222 gws.log.info(f'LOGGED_IN (MFA pending): {user.uid=} {user.roles=}')
223 return self._mfa_response(mfa)
225 self._finalize_login(req, user)
226 return LoginResponse(user=gws.props_of(user, user), redirectTo=self._redirect_target(p.to))
228 def handle_mfa_verify(self, req: gws.WebRequester, p: MfaVerifyRequest) -> LoginResponse:
229 """Verify a multi-factor payload.
231 On success, the session is replaced by a new session for the user. If
232 the adapter allows another attempt, the transaction is kept. Otherwise
233 the session is deleted.
235 Args:
236 req: Web requester.
237 p: Verification request.
239 Returns:
240 The multi-factor state, with the redirect target on success.
242 Raises:
243 ``gws.ForbiddenError``: If the session has no valid multi-factor transaction.
244 ``gws.AuthenticationError``: If the verification failed.
245 """
246 try:
247 mfa = self._mfa_verify(req, p.payload)
248 except gws.ForbiddenError:
249 self._delete_session(req)
250 raise
252 if mfa.state == gws.AuthMultiFactorState.ok:
253 self._finalize_login(req, mfa.user)
254 return self._mfa_response(mfa, self._redirect_target(p.to))
256 if mfa.state == gws.AuthMultiFactorState.retry:
257 return self._mfa_response(mfa)
259 self._delete_session(req)
260 raise gws.AuthenticationError(f'MFA: verify failed {mfa.state=}')
262 def handle_mfa_restart(self, req: gws.WebRequester, p: gws.Request) -> LoginResponse:
263 """Restart the multi-factor transaction of the current session.
265 Args:
266 req: Web requester.
267 p: Request parameters.
269 Returns:
270 The state of the new transaction.
272 Raises:
273 ``gws.ForbiddenError``: If the session has no valid transaction or it cannot be restarted. The session is deleted in this case.
274 """
275 try:
276 mfa = self._mfa_restart(req)
277 except gws.ForbiddenError:
278 self._delete_session(req)
279 raise
281 return self._mfa_response(mfa)
283 def handle_logout(self, req: gws.WebRequester) -> LogoutResponse:
284 """Log out the current user and delete the session.
286 Args:
287 req: Web requester.
289 Returns:
290 An empty response.
292 Raises:
293 ``gws.ForbiddenError``: If the session was opened by a different method.
294 """
295 if req.user.isGuest:
296 self._delete_session(req)
297 return LogoutResponse()
299 if req.session.method != self:
300 raise gws.ForbiddenError(f'wrong method for logout: {req.session.method!r}')
302 self._delete_session(req)
304 gws.log.info(f'LOGGED_OUT: user={req.user.uid!r}')
305 return LogoutResponse()
307 ##
309 def _delete_session(self, req: gws.WebRequester):
310 """Delete the current session and replace it with the placeholder session."""
311 am = self.root.app.authMgr
312 am.sessionMgr.delete(req.session)
313 req.set_session(self.deletedSession)
315 def _finalize_login(self, req: gws.WebRequester, user: gws.User):
316 """Replace the current session with a new session for the user."""
317 self._delete_session(req)
318 am = self.root.app.authMgr
319 req.set_session(am.sessionMgr.create(self, user))
320 gws.log.info(f'LOGGED_IN: {user.uid=} {user.roles=}')
322 def _redirect_target(self, s: str | None) -> str:
323 """Convert a redirect target sent by the client to a local URL path."""
325 s = (s or '').strip()
326 if not s:
327 return ''
328 if '\\' in s or any(c < ' ' or c == '\x7f' for c in s):
329 return ''
330 return '/' + s.lstrip('/')
332 ##
334 def _mfa_start(self, req: gws.WebRequester, user: gws.User) -> gws.AuthMultiFactorTransaction:
335 """Start a multi-factor transaction and keep it in a new guest session."""
336 am = self.root.app.authMgr
338 adapter = am.get_multi_factor_adapter(user.mfaUid)
339 if not adapter:
340 raise gws.ForbiddenError(f'MFA: {user.mfaUid=} unknown')
342 mfa = adapter.start(user)
343 if not mfa:
344 raise gws.ForbiddenError(f'MFA: {user.mfaUid=} start failed')
346 req.set_session(am.sessionMgr.create(self, am.guestUser))
348 self._mfa_store(req, mfa)
349 return mfa
351 def _mfa_verify(self, req: gws.WebRequester, payload: dict) -> gws.AuthMultiFactorTransaction:
352 """Verify a payload against the transaction stored in the session."""
353 mfa = self._mfa_load(req)
354 mfa = mfa.adapter.verify(mfa, payload)
356 self._mfa_store(req, mfa)
357 return mfa
359 def _mfa_restart(self, req: gws.WebRequester) -> gws.AuthMultiFactorTransaction:
360 """Restart the transaction stored in the session."""
361 mfa = self._mfa_load(req)
362 mfa = mfa.adapter.restart(mfa)
363 if not mfa:
364 raise gws.ForbiddenError(f'MFA: restart failed')
366 self._mfa_store(req, mfa)
367 return mfa
369 def _mfa_store(self, req: gws.WebRequester, mfa: gws.AuthMultiFactorTransaction):
370 """Store the transaction in the session."""
371 am = self.root.app.authMgr
373 sess_mfa = gws.u.merge({}, mfa)
374 sess_mfa['user'] = am.serialize_user(mfa.user)
375 sess_mfa['adapter'] = mfa.adapter.uid
376 req.session.set('AuthMultiFactorTransaction', sess_mfa)
378 def _mfa_load(self, req: gws.WebRequester) -> gws.AuthMultiFactorTransaction:
379 """Load the transaction from the session and check its state."""
380 am = self.root.app.authMgr
382 sess_mfa = req.session.get('AuthMultiFactorTransaction')
383 if not sess_mfa:
384 raise gws.ForbiddenError(f'MFA: transaction not found')
386 mfa = gws.AuthMultiFactorTransaction(sess_mfa)
387 mfa.adapter = gws.u.require(am.get_multi_factor_adapter(sess_mfa['adapter']))
388 mfa.user = gws.u.require(am.unserialize_user(sess_mfa['user']))
390 if not mfa.adapter.check_state(mfa):
391 raise gws.ForbiddenError(f'MFA: invalid transaction in session')
393 return mfa
395 def _mfa_response(self, mfa: gws.AuthMultiFactorTransaction, redirect_to: str = '') -> LoginResponse:
396 """Create a login response from a multi-factor transaction."""
397 return LoginResponse(
398 mfaState=mfa.state,
399 mfaMessage=mfa.message,
400 mfaCanRestart=mfa.adapter.check_restart(mfa),
401 redirectTo=redirect_to,
402 )