Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / auth.py: 100%
105 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-04 08:56 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-04 08:56 -0600
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 home page.
47 * route: ``login``
48 * template: ``/auth/login.mako``
49 """
50 # pylint: disable=duplicate-code
51 model = self.app.model
52 session = session or Session()
54 # nb. redirect to /setup if no users exist
55 user = session.query(model.User).first()
56 if not user:
57 return self.redirect(self.request.route_url("setup"))
58 # pylint: enable=duplicate-code
60 referrer = self.request.get_referrer()
62 # redirect if already logged in
63 if self.request.user:
64 self.request.session.flash(
65 f"{self.request.user} is already logged in", "error"
66 )
67 return self.redirect(referrer)
69 form = self.make_form(
70 schema=self.login_make_schema(),
71 align_buttons_right=True,
72 show_button_cancel=False,
73 show_button_reset=True,
74 button_label_submit="Login",
75 button_icon_submit="user",
76 )
78 # validate basic form data (sanity check)
79 data = form.validate()
80 if data:
82 # truly validate user credentials
83 if user := self.authenticate_user(
84 session, data["username"], data["password"]
85 ):
87 # okay now they're truly logged in
88 headers = login_user(self.request, user)
89 return self.redirect(referrer, headers=headers)
91 self.request.session.flash("Invalid user credentials", "error")
93 return {
94 "index_title": self.app.get_title(),
95 "form": form,
96 # TODO
97 # 'referrer': referrer,
98 }
100 def authenticate_user(
101 self, session, username, password
102 ): # pylint: disable=missing-function-docstring
103 auth = self.app.get_auth_handler()
104 return auth.authenticate_user(session, username, password)
106 def login_make_schema(self): # pylint: disable=empty-docstring
107 """ """
108 schema = colander.Schema()
110 # nb. we must explicitly declare the widgets in order to also
111 # specify the ref attribute. this is needed for autofocus and
112 # keydown behavior for login form.
114 schema.add(
115 colander.SchemaNode(
116 colander.String(),
117 name="username",
118 widget=widgets.TextInputWidget(
119 attributes={
120 "ref": "username",
121 }
122 ),
123 )
124 )
126 schema.add(
127 colander.SchemaNode(
128 colander.String(),
129 name="password",
130 widget=widgets.PasswordWidget(
131 attributes={
132 "ref": "password",
133 }
134 ),
135 )
136 )
138 return schema
140 def logout(self):
141 """
142 View for user logout.
144 This deletes/invalidates the current user session and then
145 redirects to the login page.
147 Note that a simple GET is sufficient; POST is not required.
149 * route: ``logout``
150 * template: n/a
151 """
152 # truly logout the user
153 headers = logout_user(self.request)
155 # TODO
156 # # redirect to home page after logout, if so configured
157 # if self.config.get_bool('wuttaweb.home_after_logout', default=False):
158 # return self.redirect(self.request.route_url('home'), headers=headers)
160 # otherwise redirect to referrer, with 'login' page as fallback
161 # TODO: should call request.get_referrer()
162 # referrer = self.request.get_referrer(default=self.request.route_url('login'))
163 referrer = self.request.route_url("login")
164 return self.redirect(referrer, headers=headers)
166 def change_password(self):
167 """
168 View allowing a user to change their own password.
170 This view shows a change-password form, and handles its
171 submission. If successful, user is redirected to home page.
173 If current user is not authenticated, no form is shown and
174 user is redirected to home page.
176 * route: ``change_password``
177 * template: ``/auth/change_password.mako``
178 """
179 if not self.request.user:
180 return self.redirect(self.request.route_url("home"))
182 if self.request.user.prevent_edit:
183 raise self.forbidden()
185 form = self.make_form(
186 schema=self.change_password_make_schema(),
187 show_button_cancel=False,
188 show_button_reset=True,
189 )
191 data = form.validate()
192 if data:
193 auth = self.app.get_auth_handler()
194 auth.set_user_password(self.request.user, data["new_password"])
195 self.request.session.flash("Your password has been changed.")
196 # TODO: should use request.get_referrer() instead
197 referrer = self.request.route_url("home")
198 return self.redirect(referrer)
200 return {"index_title": str(self.request.user), "form": form}
202 def change_password_make_schema(self): # pylint: disable=empty-docstring
203 """ """
204 schema = colander.Schema()
206 schema.add(
207 colander.SchemaNode(
208 colander.String(),
209 name="current_password",
210 widget=widgets.PasswordWidget(),
211 validator=self.change_password_validate_current_password,
212 )
213 )
215 # nb. must use different widget for Vue 3 + Oruga
216 widget = (
217 widgets.WuttaCheckedPasswordWidget()
218 if self.request.use_oruga
219 else widgets.CheckedPasswordWidget()
220 )
221 schema.add(
222 colander.SchemaNode(
223 colander.String(),
224 name="new_password",
225 widget=widget,
226 validator=self.change_password_validate_new_password,
227 )
228 )
230 return schema
232 def change_password_validate_current_password( # pylint: disable=empty-docstring
233 self, node, value
234 ):
235 """ """
236 auth = self.app.get_auth_handler()
237 user = self.request.user
238 if not auth.check_user_password(user, value):
239 node.raise_invalid("Current password is incorrect.")
241 def change_password_validate_new_password( # pylint: disable=empty-docstring
242 self, node, value
243 ):
244 """ """
245 auth = self.app.get_auth_handler()
246 user = self.request.user
247 if auth.check_user_password(user, value):
248 node.raise_invalid("New password must be different from old password.")
250 def become_root(self):
251 """
252 Elevate the current request to 'root' for full system access.
254 This is only allowed if current (authenticated) user is a
255 member of the Administrator role. Also note that GET is not
256 allowed for this view, only POST.
258 See also :meth:`stop_root()`.
259 """
260 if self.request.method != "POST":
261 raise self.forbidden()
263 if not self.request.is_admin:
264 raise self.forbidden()
266 self.request.session["is_root"] = True
267 self.request.session.flash(
268 "You have been elevated to 'root' and now have full system access"
269 )
271 url = self.request.get_referrer()
272 return self.redirect(url)
274 def stop_root(self):
275 """
276 Lower the current request from 'root' back to normal access.
278 Also note that GET is not allowed for this view, only POST.
280 See also :meth:`become_root()`.
281 """
282 if self.request.method != "POST":
283 raise self.forbidden()
285 if not self.request.is_admin:
286 raise self.forbidden()
288 self.request.session["is_root"] = False
289 self.request.session.flash("Your normal system access has been restored")
291 url = self.request.get_referrer()
292 return self.redirect(url)
294 @classmethod
295 def defaults(cls, config): # pylint: disable=empty-docstring
296 """ """
297 cls._auth_defaults(config)
299 @classmethod
300 def _auth_defaults(cls, config):
302 # login
303 config.add_route("login", "/login")
304 config.add_view(
305 cls, attr="login", route_name="login", renderer="/auth/login.mako"
306 )
308 # logout
309 config.add_route("logout", "/logout")
310 config.add_view(cls, attr="logout", route_name="logout")
312 # change password
313 config.add_route("change_password", "/change-password")
314 config.add_view(
315 cls,
316 attr="change_password",
317 route_name="change_password",
318 renderer="/auth/change_password.mako",
319 )
321 # become root
322 config.add_route("become_root", "/root/yes", request_method="POST")
323 config.add_view(cls, attr="become_root", route_name="become_root")
325 # stop root
326 config.add_route("stop_root", "/root/no", request_method="POST")
327 config.add_view(cls, attr="stop_root", route_name="stop_root")
330def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
331 base = globals()
333 AuthView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
334 "AuthView", base["AuthView"]
335 )
336 AuthView.defaults(config)
339def includeme(config): # pylint: disable=missing-function-docstring
340 defaults(config)