Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/auth.py: 100%
106 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Auth Views
25"""
27import colander
29from wuttaweb.views import View
30from wuttaweb.db import Session
31from wuttaweb.auth import login_user, logout_user
32from wuttaweb.forms import widgets
35class AuthView(View):
36 """
37 Auth views shared by all apps.
38 """
40 def login(self, session=None):
41 """
42 View for user login.
44 This view shows the login form, and handles its submission.
45 Upon successful login, user is redirected to referrer or home
46 page.
48 * route: ``login``
49 * template: ``/auth/login.mako``
50 """
51 # pylint: disable=duplicate-code
52 model = self.app.model
53 session = session or Session()
55 # nb. redirect to /setup if no users exist
56 user = session.query(model.User).first()
57 if not user:
58 return self.redirect(self.request.route_url("setup"))
59 # pylint: enable=duplicate-code
61 referrer = self.request.get_referrer()
63 # redirect if already logged in
64 if self.request.user:
65 self.request.session.flash(
66 f"{self.request.user} is already logged in", "error"
67 )
68 return self.redirect(referrer)
70 form = self.make_form(
71 schema=self.login_make_schema(),
72 align_buttons_right=True,
73 show_button_cancel=False,
74 show_button_reset=True,
75 button_label_submit="Login",
76 button_icon_submit="user",
77 )
79 # validate basic form data (sanity check)
80 data = form.validate()
81 if data:
83 # truly validate user credentials
84 if user := self.authenticate_user(
85 session, data["username"], data["password"]
86 ):
88 # okay now they're truly logged in
89 headers = login_user(self.request, user)
90 # prefer url from session over effective referrer
91 referrer = self.request.session.pop("url_after_login", referrer)
92 return self.redirect(referrer, headers=headers)
94 self.request.session.flash("Invalid user credentials", "error")
96 return {
97 "index_title": self.app.get_title(),
98 "form": form,
99 # TODO
100 # 'referrer': referrer,
101 }
103 def authenticate_user(
104 self, session, username, password
105 ): # pylint: disable=missing-function-docstring
106 auth = self.app.get_auth_handler()
107 return auth.authenticate_user(session, username, password)
109 def login_make_schema(self): # pylint: disable=empty-docstring
110 """ """
111 schema = colander.Schema()
113 # nb. we must explicitly declare the widgets in order to also
114 # specify the ref attribute. this is needed for autofocus and
115 # keydown behavior for login form.
117 schema.add(
118 colander.SchemaNode(
119 colander.String(),
120 name="username",
121 widget=widgets.TextInputWidget(
122 attributes={
123 "ref": "username",
124 }
125 ),
126 )
127 )
129 schema.add(
130 colander.SchemaNode(
131 colander.String(),
132 name="password",
133 widget=widgets.PasswordWidget(
134 attributes={
135 "ref": "password",
136 }
137 ),
138 )
139 )
141 return schema
143 def logout(self):
144 """
145 View for user logout.
147 This deletes/invalidates the current user session and then
148 redirects to the login page.
150 Note that a simple GET is sufficient; POST is not required.
152 * route: ``logout``
153 * template: n/a
154 """
155 # truly logout the user
156 headers = logout_user(self.request)
158 # TODO
159 # # redirect to home page after logout, if so configured
160 # if self.config.get_bool('wuttaweb.home_after_logout', default=False):
161 # return self.redirect(self.request.route_url('home'), headers=headers)
163 # otherwise redirect to referrer, with 'login' page as fallback
164 # TODO: should call request.get_referrer()
165 # referrer = self.request.get_referrer(default=self.request.route_url('login'))
166 referrer = self.request.route_url("login")
167 return self.redirect(referrer, headers=headers)
169 def change_password(self):
170 """
171 View allowing a user to change their own password.
173 This view shows a change-password form, and handles its
174 submission. If successful, user is redirected to home page.
176 If current user is not authenticated, no form is shown and
177 user is redirected to home page.
179 * route: ``change_password``
180 * template: ``/auth/change_password.mako``
181 """
182 if not self.request.user:
183 return self.redirect(self.request.route_url("home"))
185 if self.request.user.prevent_edit:
186 raise self.forbidden()
188 form = self.make_form(
189 schema=self.change_password_make_schema(),
190 show_button_cancel=False,
191 show_button_reset=True,
192 )
194 data = form.validate()
195 if data:
196 auth = self.app.get_auth_handler()
197 auth.set_user_password(self.request.user, data["new_password"])
198 self.request.session.flash("Your password has been changed.")
199 # TODO: should use request.get_referrer() instead
200 referrer = self.request.route_url("home")
201 return self.redirect(referrer)
203 return {"index_title": str(self.request.user), "form": form}
205 def change_password_make_schema(self): # pylint: disable=empty-docstring
206 """ """
207 schema = colander.Schema()
209 schema.add(
210 colander.SchemaNode(
211 colander.String(),
212 name="current_password",
213 widget=widgets.PasswordWidget(),
214 validator=self.change_password_validate_current_password,
215 )
216 )
218 # nb. must use different widget for Vue 3 + Oruga
219 widget = (
220 widgets.WuttaCheckedPasswordWidget()
221 if self.request.use_oruga
222 else widgets.CheckedPasswordWidget()
223 )
224 schema.add(
225 colander.SchemaNode(
226 colander.String(),
227 name="new_password",
228 widget=widget,
229 validator=self.change_password_validate_new_password,
230 )
231 )
233 return schema
235 def change_password_validate_current_password( # pylint: disable=empty-docstring
236 self, node, value
237 ):
238 """ """
239 auth = self.app.get_auth_handler()
240 user = self.request.user
241 if not auth.check_user_password(user, value):
242 node.raise_invalid("Current password is incorrect.")
244 def change_password_validate_new_password( # pylint: disable=empty-docstring
245 self, node, value
246 ):
247 """ """
248 auth = self.app.get_auth_handler()
249 user = self.request.user
250 if auth.check_user_password(user, value):
251 node.raise_invalid("New password must be different from old password.")
253 def become_root(self):
254 """
255 Elevate the current request to 'root' for full system access.
257 This is only allowed if current (authenticated) user is a
258 member of the Administrator role. Also note that GET is not
259 allowed for this view, only POST.
261 See also :meth:`stop_root()`.
262 """
263 if self.request.method != "POST":
264 raise self.forbidden()
266 if not self.request.is_admin:
267 raise self.forbidden()
269 self.request.session["is_root"] = True
270 self.request.session.flash(
271 "You have been elevated to 'root' and now have full system access"
272 )
274 url = self.request.get_referrer()
275 return self.redirect(url)
277 def stop_root(self):
278 """
279 Lower the current request from 'root' back to normal access.
281 Also note that GET is not allowed for this view, only POST.
283 See also :meth:`become_root()`.
284 """
285 if self.request.method != "POST":
286 raise self.forbidden()
288 if not self.request.is_admin:
289 raise self.forbidden()
291 self.request.session["is_root"] = False
292 self.request.session.flash("Your normal system access has been restored")
294 url = self.request.get_referrer()
295 return self.redirect(url)
297 @classmethod
298 def defaults(cls, config): # pylint: disable=empty-docstring
299 """ """
300 cls._auth_defaults(config)
302 @classmethod
303 def _auth_defaults(cls, config):
305 # login
306 config.add_route("login", "/login")
307 config.add_view(
308 cls, attr="login", route_name="login", renderer="/auth/login.mako"
309 )
311 # logout
312 config.add_route("logout", "/logout")
313 config.add_view(cls, attr="logout", route_name="logout")
315 # change password
316 config.add_route("change_password", "/change-password")
317 config.add_view(
318 cls,
319 attr="change_password",
320 route_name="change_password",
321 renderer="/auth/change_password.mako",
322 )
324 # become root
325 config.add_route("become_root", "/root/yes", request_method="POST")
326 config.add_view(cls, attr="become_root", route_name="become_root")
328 # stop root
329 config.add_route("stop_root", "/root/no", request_method="POST")
330 config.add_view(cls, attr="stop_root", route_name="stop_root")
333def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
334 base = globals()
336 AuthView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
337 "AuthView", base["AuthView"]
338 )
339 AuthView.defaults(config)
342def includeme(config): # pylint: disable=missing-function-docstring
343 defaults(config)