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

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""" 

26 

27import colander 

28 

29from wuttaweb.views import View 

30from wuttaweb.db import Session 

31from wuttaweb.auth import login_user, logout_user 

32from wuttaweb.forms import widgets 

33 

34 

35class AuthView(View): 

36 """ 

37 Auth views shared by all apps. 

38 """ 

39 

40 def login(self, session=None): 

41 """ 

42 View for user login. 

43 

44 This view shows the login form, and handles its submission. 

45 Upon successful login, user is redirected to referrer or home 

46 page. 

47 

48 * route: ``login`` 

49 * template: ``/auth/login.mako`` 

50 """ 

51 # pylint: disable=duplicate-code 

52 model = self.app.model 

53 session = session or Session() 

54 

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 

60 

61 referrer = self.request.get_referrer() 

62 

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) 

69 

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 ) 

78 

79 # validate basic form data (sanity check) 

80 data = form.validate() 

81 if data: 

82 

83 # truly validate user credentials 

84 if user := self.authenticate_user( 

85 session, data["username"], data["password"] 

86 ): 

87 

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) 

93 

94 self.request.session.flash("Invalid user credentials", "error") 

95 

96 return { 

97 "index_title": self.app.get_title(), 

98 "form": form, 

99 # TODO 

100 # 'referrer': referrer, 

101 } 

102 

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) 

108 

109 def login_make_schema(self): # pylint: disable=empty-docstring 

110 """ """ 

111 schema = colander.Schema() 

112 

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. 

116 

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 ) 

128 

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 ) 

140 

141 return schema 

142 

143 def logout(self): 

144 """ 

145 View for user logout. 

146 

147 This deletes/invalidates the current user session and then 

148 redirects to the login page. 

149 

150 Note that a simple GET is sufficient; POST is not required. 

151 

152 * route: ``logout`` 

153 * template: n/a 

154 """ 

155 # truly logout the user 

156 headers = logout_user(self.request) 

157 

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) 

162 

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) 

168 

169 def change_password(self): 

170 """ 

171 View allowing a user to change their own password. 

172 

173 This view shows a change-password form, and handles its 

174 submission. If successful, user is redirected to home page. 

175 

176 If current user is not authenticated, no form is shown and 

177 user is redirected to home page. 

178 

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")) 

184 

185 if self.request.user.prevent_edit: 

186 raise self.forbidden() 

187 

188 form = self.make_form( 

189 schema=self.change_password_make_schema(), 

190 show_button_cancel=False, 

191 show_button_reset=True, 

192 ) 

193 

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) 

202 

203 return {"index_title": str(self.request.user), "form": form} 

204 

205 def change_password_make_schema(self): # pylint: disable=empty-docstring 

206 """ """ 

207 schema = colander.Schema() 

208 

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 ) 

217 

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 ) 

232 

233 return schema 

234 

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.") 

243 

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.") 

252 

253 def become_root(self): 

254 """ 

255 Elevate the current request to 'root' for full system access. 

256 

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. 

260 

261 See also :meth:`stop_root()`. 

262 """ 

263 if self.request.method != "POST": 

264 raise self.forbidden() 

265 

266 if not self.request.is_admin: 

267 raise self.forbidden() 

268 

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 ) 

273 

274 url = self.request.get_referrer() 

275 return self.redirect(url) 

276 

277 def stop_root(self): 

278 """ 

279 Lower the current request from 'root' back to normal access. 

280 

281 Also note that GET is not allowed for this view, only POST. 

282 

283 See also :meth:`become_root()`. 

284 """ 

285 if self.request.method != "POST": 

286 raise self.forbidden() 

287 

288 if not self.request.is_admin: 

289 raise self.forbidden() 

290 

291 self.request.session["is_root"] = False 

292 self.request.session.flash("Your normal system access has been restored") 

293 

294 url = self.request.get_referrer() 

295 return self.redirect(url) 

296 

297 @classmethod 

298 def defaults(cls, config): # pylint: disable=empty-docstring 

299 """ """ 

300 cls._auth_defaults(config) 

301 

302 @classmethod 

303 def _auth_defaults(cls, config): 

304 

305 # login 

306 config.add_route("login", "/login") 

307 config.add_view( 

308 cls, attr="login", route_name="login", renderer="/auth/login.mako" 

309 ) 

310 

311 # logout 

312 config.add_route("logout", "/logout") 

313 config.add_view(cls, attr="logout", route_name="logout") 

314 

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 ) 

323 

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") 

327 

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") 

331 

332 

333def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

334 base = globals() 

335 

336 AuthView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

337 "AuthView", base["AuthView"] 

338 ) 

339 AuthView.defaults(config) 

340 

341 

342def includeme(config): # pylint: disable=missing-function-docstring 

343 defaults(config)