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

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 home page. 

46 

47 * route: ``login`` 

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

49 """ 

50 # pylint: disable=duplicate-code 

51 model = self.app.model 

52 session = session or Session() 

53 

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 

59 

60 referrer = self.request.get_referrer() 

61 

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) 

68 

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 ) 

77 

78 # validate basic form data (sanity check) 

79 data = form.validate() 

80 if data: 

81 

82 # truly validate user credentials 

83 if user := self.authenticate_user( 

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

85 ): 

86 

87 # okay now they're truly logged in 

88 headers = login_user(self.request, user) 

89 return self.redirect(referrer, headers=headers) 

90 

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

92 

93 return { 

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

95 "form": form, 

96 # TODO 

97 # 'referrer': referrer, 

98 } 

99 

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) 

105 

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

107 """ """ 

108 schema = colander.Schema() 

109 

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. 

113 

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 ) 

125 

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 ) 

137 

138 return schema 

139 

140 def logout(self): 

141 """ 

142 View for user logout. 

143 

144 This deletes/invalidates the current user session and then 

145 redirects to the login page. 

146 

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

148 

149 * route: ``logout`` 

150 * template: n/a 

151 """ 

152 # truly logout the user 

153 headers = logout_user(self.request) 

154 

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) 

159 

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) 

165 

166 def change_password(self): 

167 """ 

168 View allowing a user to change their own password. 

169 

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

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

172 

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

174 user is redirected to home page. 

175 

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

181 

182 if self.request.user.prevent_edit: 

183 raise self.forbidden() 

184 

185 form = self.make_form( 

186 schema=self.change_password_make_schema(), 

187 show_button_cancel=False, 

188 show_button_reset=True, 

189 ) 

190 

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) 

199 

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

201 

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

203 """ """ 

204 schema = colander.Schema() 

205 

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 ) 

214 

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 ) 

229 

230 return schema 

231 

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

240 

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

249 

250 def become_root(self): 

251 """ 

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

253 

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. 

257 

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

259 """ 

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

261 raise self.forbidden() 

262 

263 if not self.request.is_admin: 

264 raise self.forbidden() 

265 

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 ) 

270 

271 url = self.request.get_referrer() 

272 return self.redirect(url) 

273 

274 def stop_root(self): 

275 """ 

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

277 

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

279 

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

281 """ 

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

283 raise self.forbidden() 

284 

285 if not self.request.is_admin: 

286 raise self.forbidden() 

287 

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

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

290 

291 url = self.request.get_referrer() 

292 return self.redirect(url) 

293 

294 @classmethod 

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

296 """ """ 

297 cls._auth_defaults(config) 

298 

299 @classmethod 

300 def _auth_defaults(cls, config): 

301 

302 # login 

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

304 config.add_view( 

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

306 ) 

307 

308 # logout 

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

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

311 

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 ) 

320 

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

324 

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

328 

329 

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

331 base = globals() 

332 

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

334 "AuthView", base["AuthView"] 

335 ) 

336 AuthView.defaults(config) 

337 

338 

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

340 defaults(config)