Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/auth.py: 100%

103 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2024-12-28 21:19 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 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 model = self.app.model 

51 session = session or Session() 

52 auth = self.app.get_auth_handler() 

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 

59 referrer = self.request.get_referrer() 

60 

61 # redirect if already logged in 

62 if self.request.user: 

63 self.request.session.flash(f"{self.request.user} is already logged in", 'error') 

64 return self.redirect(referrer) 

65 

66 form = self.make_form(schema=self.login_make_schema(), 

67 align_buttons_right=True, 

68 show_button_cancel=False, 

69 show_button_reset=True, 

70 button_label_submit="Login", 

71 button_icon_submit='user') 

72 

73 # validate basic form data (sanity check) 

74 data = form.validate() 

75 if data: 

76 

77 # truly validate user credentials 

78 user = auth.authenticate_user(session, data['username'], data['password']) 

79 if user: 

80 

81 # okay now they're truly logged in 

82 headers = login_user(self.request, user) 

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

84 

85 else: 

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

87 

88 return { 

89 'index_title': self.app.get_title(), 

90 'form': form, 

91 # TODO 

92 # 'referrer': referrer, 

93 } 

94 

95 def login_make_schema(self): 

96 schema = colander.Schema() 

97 

98 # nb. we must explicitly declare the widgets in order to also 

99 # specify the ref attribute. this is needed for autofocus and 

100 # keydown behavior for login form. 

101 

102 schema.add(colander.SchemaNode( 

103 colander.String(), 

104 name='username', 

105 widget=widgets.TextInputWidget(attributes={ 

106 'ref': 'username', 

107 }))) 

108 

109 schema.add(colander.SchemaNode( 

110 colander.String(), 

111 name='password', 

112 widget=widgets.PasswordWidget(attributes={ 

113 'ref': 'password', 

114 }))) 

115 

116 return schema 

117 

118 def logout(self): 

119 """ 

120 View for user logout. 

121 

122 This deletes/invalidates the current user session and then 

123 redirects to the login page. 

124 

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

126 

127 * route: ``logout`` 

128 * template: n/a 

129 """ 

130 # truly logout the user 

131 headers = logout_user(self.request) 

132 

133 # TODO 

134 # # redirect to home page after logout, if so configured 

135 # if self.config.get_bool('wuttaweb.home_after_logout', default=False): 

136 # return self.redirect(self.request.route_url('home'), headers=headers) 

137 

138 # otherwise redirect to referrer, with 'login' page as fallback 

139 # TODO: should call request.get_referrer() 

140 # referrer = self.request.get_referrer(default=self.request.route_url('login')) 

141 referrer = self.request.route_url('login') 

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

143 

144 def change_password(self): 

145 """ 

146 View allowing a user to change their own password. 

147 

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

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

150 

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

152 user is redirected to home page. 

153 

154 * route: ``change_password`` 

155 * template: ``/auth/change_password.mako`` 

156 """ 

157 if not self.request.user: 

158 return self.redirect(self.request.route_url('home')) 

159 

160 if self.request.user.prevent_edit: 

161 raise self.forbidden() 

162 

163 form = self.make_form(schema=self.change_password_make_schema(), 

164 show_button_cancel=False, 

165 show_button_reset=True) 

166 

167 data = form.validate() 

168 if data: 

169 auth = self.app.get_auth_handler() 

170 auth.set_user_password(self.request.user, data['new_password']) 

171 self.request.session.flash("Your password has been changed.") 

172 # TODO: should use request.get_referrer() instead 

173 referrer = self.request.route_url('home') 

174 return self.redirect(referrer) 

175 

176 return {'index_title': str(self.request.user), 

177 'form': form} 

178 

179 def change_password_make_schema(self): 

180 schema = colander.Schema() 

181 

182 schema.add(colander.SchemaNode( 

183 colander.String(), 

184 name='current_password', 

185 widget=widgets.PasswordWidget(), 

186 validator=self.change_password_validate_current_password)) 

187 

188 schema.add(colander.SchemaNode( 

189 colander.String(), 

190 name='new_password', 

191 widget=widgets.CheckedPasswordWidget(), 

192 validator=self.change_password_validate_new_password)) 

193 

194 return schema 

195 

196 def change_password_validate_current_password(self, node, value): 

197 auth = self.app.get_auth_handler() 

198 user = self.request.user 

199 if not auth.check_user_password(user, value): 

200 node.raise_invalid("Current password is incorrect.") 

201 

202 def change_password_validate_new_password(self, node, value): 

203 auth = self.app.get_auth_handler() 

204 user = self.request.user 

205 if auth.check_user_password(user, value): 

206 node.raise_invalid("New password must be different from old password.") 

207 

208 def become_root(self): 

209 """ 

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

211 

212 This is only allowed if current (authenticated) user is a 

213 member of the Administrator role. Also note that GET is not 

214 allowed for this view, only POST. 

215 

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

217 """ 

218 if self.request.method != 'POST': 

219 raise self.forbidden() 

220 

221 if not self.request.is_admin: 

222 raise self.forbidden() 

223 

224 self.request.session['is_root'] = True 

225 self.request.session.flash("You have been elevated to 'root' and now have full system access") 

226 

227 url = self.request.get_referrer() 

228 return self.redirect(url) 

229 

230 def stop_root(self): 

231 """ 

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

233 

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

235 

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

237 """ 

238 if self.request.method != 'POST': 

239 raise self.forbidden() 

240 

241 if not self.request.is_admin: 

242 raise self.forbidden() 

243 

244 self.request.session['is_root'] = False 

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

246 

247 url = self.request.get_referrer() 

248 return self.redirect(url) 

249 

250 @classmethod 

251 def defaults(cls, config): 

252 cls._auth_defaults(config) 

253 

254 @classmethod 

255 def _auth_defaults(cls, config): 

256 

257 # login 

258 config.add_route('login', '/login') 

259 config.add_view(cls, attr='login', 

260 route_name='login', 

261 renderer='/auth/login.mako') 

262 

263 # logout 

264 config.add_route('logout', '/logout') 

265 config.add_view(cls, attr='logout', 

266 route_name='logout') 

267 

268 # change password 

269 config.add_route('change_password', '/change-password') 

270 config.add_view(cls, attr='change_password', 

271 route_name='change_password', 

272 renderer='/auth/change_password.mako') 

273 

274 # become root 

275 config.add_route('become_root', '/root/yes', 

276 request_method='POST') 

277 config.add_view(cls, attr='become_root', 

278 route_name='become_root') 

279 

280 # stop root 

281 config.add_route('stop_root', '/root/no', 

282 request_method='POST') 

283 config.add_view(cls, attr='stop_root', 

284 route_name='stop_root') 

285 

286 

287def defaults(config, **kwargs): 

288 base = globals() 

289 

290 AuthView = kwargs.get('AuthView', base['AuthView']) 

291 AuthView.defaults(config) 

292 

293 

294def includeme(config): 

295 defaults(config)