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

101 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-15 16:24 -0500

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 form = self.make_form(schema=self.change_password_make_schema(), 

161 show_button_cancel=False, 

162 show_button_reset=True) 

163 

164 data = form.validate() 

165 if data: 

166 auth = self.app.get_auth_handler() 

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

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

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

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

171 return self.redirect(referrer) 

172 

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

174 'form': form} 

175 

176 def change_password_make_schema(self): 

177 schema = colander.Schema() 

178 

179 schema.add(colander.SchemaNode( 

180 colander.String(), 

181 name='current_password', 

182 widget=widgets.PasswordWidget(), 

183 validator=self.change_password_validate_current_password)) 

184 

185 schema.add(colander.SchemaNode( 

186 colander.String(), 

187 name='new_password', 

188 widget=widgets.CheckedPasswordWidget(), 

189 validator=self.change_password_validate_new_password)) 

190 

191 return schema 

192 

193 def change_password_validate_current_password(self, node, value): 

194 auth = self.app.get_auth_handler() 

195 user = self.request.user 

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

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

198 

199 def change_password_validate_new_password(self, node, value): 

200 auth = self.app.get_auth_handler() 

201 user = self.request.user 

202 if auth.check_user_password(user, value): 

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

204 

205 def become_root(self): 

206 """ 

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

208 

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

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

211 allowed for this view, only POST. 

212 

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

214 """ 

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

216 raise self.forbidden() 

217 

218 if not self.request.is_admin: 

219 raise self.forbidden() 

220 

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

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

223 

224 url = self.request.get_referrer() 

225 return self.redirect(url) 

226 

227 def stop_root(self): 

228 """ 

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

230 

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

232 

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

234 """ 

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

236 raise self.forbidden() 

237 

238 if not self.request.is_admin: 

239 raise self.forbidden() 

240 

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

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

243 

244 url = self.request.get_referrer() 

245 return self.redirect(url) 

246 

247 @classmethod 

248 def defaults(cls, config): 

249 cls._auth_defaults(config) 

250 

251 @classmethod 

252 def _auth_defaults(cls, config): 

253 

254 # login 

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

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

257 route_name='login', 

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

259 

260 # logout 

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

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

263 route_name='logout') 

264 

265 # change password 

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

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

268 route_name='change_password', 

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

270 

271 # become root 

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

273 request_method='POST') 

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

275 route_name='become_root') 

276 

277 # stop root 

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

279 request_method='POST') 

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

281 route_name='stop_root') 

282 

283 

284def defaults(config, **kwargs): 

285 base = globals() 

286 

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

288 AuthView.defaults(config) 

289 

290 

291def includeme(config): 

292 defaults(config)