##// END OF EJS Templates
password-reset: improved error reporting for captch and empty email
marcink -
r1474:1307b88c default
parent child Browse files
Show More
@@ -1,390 +1,400
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import time
22 22 import collections
23 23 import datetime
24 24 import formencode
25 25 import logging
26 26 import urlparse
27 27
28 28 from pylons import url
29 29 from pyramid.httpexceptions import HTTPFound
30 30 from pyramid.view import view_config
31 31 from recaptcha.client.captcha import submit
32 32
33 33 from rhodecode.authentication.base import authenticate, HTTP_TYPE
34 34 from rhodecode.events import UserRegistered
35 35 from rhodecode.lib import helpers as h
36 36 from rhodecode.lib.auth import (
37 37 AuthUser, HasPermissionAnyDecorator, CSRFRequired)
38 38 from rhodecode.lib.base import get_ip_addr
39 39 from rhodecode.lib.exceptions import UserCreationError
40 40 from rhodecode.lib.utils2 import safe_str
41 41 from rhodecode.model.db import User, UserApiKeys
42 42 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
43 43 from rhodecode.model.meta import Session
44 44 from rhodecode.model.auth_token import AuthTokenModel
45 45 from rhodecode.model.settings import SettingsModel
46 46 from rhodecode.model.user import UserModel
47 47 from rhodecode.translation import _
48 48
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52 CaptchaData = collections.namedtuple(
53 53 'CaptchaData', 'active, private_key, public_key')
54 54
55 55
56 56 def _store_user_in_session(session, username, remember=False):
57 57 user = User.get_by_username(username, case_insensitive=True)
58 58 auth_user = AuthUser(user.user_id)
59 59 auth_user.set_authenticated()
60 60 cs = auth_user.get_cookie_store()
61 61 session['rhodecode_user'] = cs
62 62 user.update_lastlogin()
63 63 Session().commit()
64 64
65 65 # If they want to be remembered, update the cookie
66 66 if remember:
67 67 _year = (datetime.datetime.now() +
68 68 datetime.timedelta(seconds=60 * 60 * 24 * 365))
69 69 session._set_cookie_expires(_year)
70 70
71 71 session.save()
72 72
73 73 safe_cs = cs.copy()
74 74 safe_cs['password'] = '****'
75 75 log.info('user %s is now authenticated and stored in '
76 76 'session, session attrs %s', username, safe_cs)
77 77
78 78 # dumps session attrs back to cookie
79 79 session._update_cookie_out()
80 80 # we set new cookie
81 81 headers = None
82 82 if session.request['set_cookie']:
83 83 # send set-cookie headers back to response to update cookie
84 84 headers = [('Set-Cookie', session.request['cookie_out'])]
85 85 return headers
86 86
87 87
88 88 def get_came_from(request):
89 89 came_from = safe_str(request.GET.get('came_from', ''))
90 90 parsed = urlparse.urlparse(came_from)
91 91 allowed_schemes = ['http', 'https']
92 92 if parsed.scheme and parsed.scheme not in allowed_schemes:
93 93 log.error('Suspicious URL scheme detected %s for url %s' %
94 94 (parsed.scheme, parsed))
95 95 came_from = url('home')
96 96 elif parsed.netloc and request.host != parsed.netloc:
97 97 log.error('Suspicious NETLOC detected %s for url %s server url '
98 98 'is: %s' % (parsed.netloc, parsed, request.host))
99 99 came_from = url('home')
100 100 elif any(bad_str in parsed.path for bad_str in ('\r', '\n')):
101 101 log.error('Header injection detected `%s` for url %s server url ' %
102 102 (parsed.path, parsed))
103 103 came_from = url('home')
104 104
105 105 return came_from or url('home')
106 106
107 107
108 108 class LoginView(object):
109 109
110 110 def __init__(self, context, request):
111 111 self.request = request
112 112 self.context = context
113 113 self.session = request.session
114 114 self._rhodecode_user = request.user
115 115
116 116 def _get_template_context(self):
117 117 return {
118 118 'came_from': get_came_from(self.request),
119 119 'defaults': {},
120 120 'errors': {},
121 121 }
122 122
123 123 def _get_captcha_data(self):
124 124 settings = SettingsModel().get_all_settings()
125 125 private_key = settings.get('rhodecode_captcha_private_key')
126 126 public_key = settings.get('rhodecode_captcha_public_key')
127 127 active = bool(private_key)
128 128 return CaptchaData(
129 129 active=active, private_key=private_key, public_key=public_key)
130 130
131 131 @view_config(
132 132 route_name='login', request_method='GET',
133 133 renderer='rhodecode:templates/login.mako')
134 134 def login(self):
135 135 came_from = get_came_from(self.request)
136 136 user = self.request.user
137 137
138 138 # redirect if already logged in
139 139 if user.is_authenticated and not user.is_default and user.ip_allowed:
140 140 raise HTTPFound(came_from)
141 141
142 142 # check if we use headers plugin, and try to login using it.
143 143 try:
144 144 log.debug('Running PRE-AUTH for headers based authentication')
145 145 auth_info = authenticate(
146 146 '', '', self.request.environ, HTTP_TYPE, skip_missing=True)
147 147 if auth_info:
148 148 headers = _store_user_in_session(
149 149 self.session, auth_info.get('username'))
150 150 raise HTTPFound(came_from, headers=headers)
151 151 except UserCreationError as e:
152 152 log.error(e)
153 153 self.session.flash(e, queue='error')
154 154
155 155 return self._get_template_context()
156 156
157 157 @view_config(
158 158 route_name='login', request_method='POST',
159 159 renderer='rhodecode:templates/login.mako')
160 160 def login_post(self):
161 161 came_from = get_came_from(self.request)
162 162
163 163 login_form = LoginForm()()
164 164
165 165 try:
166 166 self.session.invalidate()
167 167 form_result = login_form.to_python(self.request.params)
168 168 # form checks for username/password, now we're authenticated
169 169 headers = _store_user_in_session(
170 170 self.session,
171 171 username=form_result['username'],
172 172 remember=form_result['remember'])
173 173 log.debug('Redirecting to "%s" after login.', came_from)
174 174 raise HTTPFound(came_from, headers=headers)
175 175 except formencode.Invalid as errors:
176 176 defaults = errors.value
177 177 # remove password from filling in form again
178 178 defaults.pop('password', None)
179 179 render_ctx = self._get_template_context()
180 180 render_ctx.update({
181 181 'errors': errors.error_dict,
182 182 'defaults': defaults,
183 183 })
184 184 return render_ctx
185 185
186 186 except UserCreationError as e:
187 187 # headers auth or other auth functions that create users on
188 188 # the fly can throw this exception signaling that there's issue
189 189 # with user creation, explanation should be provided in
190 190 # Exception itself
191 191 self.session.flash(e, queue='error')
192 192 return self._get_template_context()
193 193
194 194 @CSRFRequired()
195 195 @view_config(route_name='logout', request_method='POST')
196 196 def logout(self):
197 197 user = self.request.user
198 198 log.info('Deleting session for user: `%s`', user)
199 199 self.session.delete()
200 200 return HTTPFound(url('home'))
201 201
202 202 @HasPermissionAnyDecorator(
203 203 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
204 204 @view_config(
205 205 route_name='register', request_method='GET',
206 206 renderer='rhodecode:templates/register.mako',)
207 207 def register(self, defaults=None, errors=None):
208 208 defaults = defaults or {}
209 209 errors = errors or {}
210 210
211 211 settings = SettingsModel().get_all_settings()
212 212 register_message = settings.get('rhodecode_register_message') or ''
213 213 captcha = self._get_captcha_data()
214 214 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
215 215 .AuthUser.permissions['global']
216 216
217 217 render_ctx = self._get_template_context()
218 218 render_ctx.update({
219 219 'defaults': defaults,
220 220 'errors': errors,
221 221 'auto_active': auto_active,
222 222 'captcha_active': captcha.active,
223 223 'captcha_public_key': captcha.public_key,
224 224 'register_message': register_message,
225 225 })
226 226 return render_ctx
227 227
228 228 @HasPermissionAnyDecorator(
229 229 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
230 230 @view_config(
231 231 route_name='register', request_method='POST',
232 232 renderer='rhodecode:templates/register.mako')
233 233 def register_post(self):
234 234 captcha = self._get_captcha_data()
235 235 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
236 236 .AuthUser.permissions['global']
237 237
238 238 register_form = RegisterForm()()
239 239 try:
240 240 form_result = register_form.to_python(self.request.params)
241 241 form_result['active'] = auto_active
242 242
243 243 if captcha.active:
244 244 response = submit(
245 245 self.request.params.get('recaptcha_challenge_field'),
246 246 self.request.params.get('recaptcha_response_field'),
247 247 private_key=captcha.private_key,
248 248 remoteip=get_ip_addr(self.request.environ))
249 249 if not response.is_valid:
250 250 _value = form_result
251 251 _msg = _('Bad captcha')
252 252 error_dict = {'recaptcha_field': _msg}
253 253 raise formencode.Invalid(_msg, _value, None,
254 254 error_dict=error_dict)
255 255
256 256 new_user = UserModel().create_registration(form_result)
257 257 event = UserRegistered(user=new_user, session=self.session)
258 258 self.request.registry.notify(event)
259 259 self.session.flash(
260 260 _('You have successfully registered with RhodeCode'),
261 261 queue='success')
262 262 Session().commit()
263 263
264 264 redirect_ro = self.request.route_path('login')
265 265 raise HTTPFound(redirect_ro)
266 266
267 267 except formencode.Invalid as errors:
268 268 errors.value.pop('password', None)
269 269 errors.value.pop('password_confirmation', None)
270 270 return self.register(
271 271 defaults=errors.value, errors=errors.error_dict)
272 272
273 273 except UserCreationError as e:
274 274 # container auth or other auth functions that create users on
275 275 # the fly can throw this exception signaling that there's issue
276 276 # with user creation, explanation should be provided in
277 277 # Exception itself
278 278 self.session.flash(e, queue='error')
279 279 return self.register()
280 280
281 281 @view_config(
282 282 route_name='reset_password', request_method=('GET', 'POST'),
283 283 renderer='rhodecode:templates/password_reset.mako')
284 284 def password_reset(self):
285 285 captcha = self._get_captcha_data()
286 286
287 287 render_ctx = {
288 288 'captcha_active': captcha.active,
289 289 'captcha_public_key': captcha.public_key,
290 290 'defaults': {},
291 291 'errors': {},
292 292 }
293 293
294 294 # always send implicit message to prevent from discovery of
295 295 # matching emails
296 296 msg = _('If such email exists, a password reset link was sent to it.')
297 297
298 298 if self.request.POST:
299 299 if h.HasPermissionAny('hg.password_reset.disabled')():
300 300 _email = self.request.POST.get('email', '')
301 301 log.error('Failed attempt to reset password for `%s`.', _email)
302 302 self.session.flash(_('Password reset has been disabled.'),
303 303 queue='error')
304 304 return HTTPFound(self.request.route_path('reset_password'))
305 305
306 306 password_reset_form = PasswordResetForm()()
307 307 try:
308 308 form_result = password_reset_form.to_python(
309 309 self.request.params)
310 310 user_email = form_result['email']
311 311
312 312 if captcha.active:
313 313 response = submit(
314 314 self.request.params.get('recaptcha_challenge_field'),
315 315 self.request.params.get('recaptcha_response_field'),
316 316 private_key=captcha.private_key,
317 317 remoteip=get_ip_addr(self.request.environ))
318 318 if not response.is_valid:
319 319 _value = form_result
320 320 _msg = _('Bad captcha')
321 321 error_dict = {'recaptcha_field': _msg}
322 322 raise formencode.Invalid(
323 323 _msg, _value, None, error_dict=error_dict)
324
324 325 # Generate reset URL and send mail.
325 326 user = User.get_by_email(user_email)
326 327
327 328 # generate password reset token that expires in 10minutes
328 329 desc = 'Generated token for password reset from {}'.format(
329 330 datetime.datetime.now().isoformat())
330 331 reset_token = AuthTokenModel().create(
331 332 user, lifetime=10,
332 333 description=desc,
333 334 role=UserApiKeys.ROLE_PASSWORD_RESET)
334 335 Session().commit()
335 336
336 337 log.debug('Successfully created password recovery token')
337 338 password_reset_url = self.request.route_url(
338 339 'reset_password_confirmation',
339 340 _query={'key': reset_token.api_key})
340 341 UserModel().reset_password_link(
341 342 form_result, password_reset_url)
342 343 # Display success message and redirect.
343 344 self.session.flash(msg, queue='success')
344 345 return HTTPFound(self.request.route_path('reset_password'))
345 346
346 347 except formencode.Invalid as errors:
347 348 render_ctx.update({
348 349 'defaults': errors.value,
350 'errors': errors.error_dict,
349 351 })
352 if not self.request.params.get('email'):
353 # case of empty email, we want to report that
354 return render_ctx
355
356 if 'recaptcha_field' in errors.error_dict:
357 # case of failed captcha
358 return render_ctx
359
350 360 log.debug('faking response on invalid password reset')
351 361 # make this take 2s, to prevent brute forcing.
352 362 time.sleep(2)
353 363 self.session.flash(msg, queue='success')
354 364 return HTTPFound(self.request.route_path('reset_password'))
355 365
356 366 return render_ctx
357 367
358 368 @view_config(route_name='reset_password_confirmation',
359 369 request_method='GET')
360 370 def password_reset_confirmation(self):
361 371
362 372 if self.request.GET and self.request.GET.get('key'):
363 373 # make this take 2s, to prevent brute forcing.
364 374 time.sleep(2)
365 375
366 376 token = AuthTokenModel().get_auth_token(
367 377 self.request.GET.get('key'))
368 378
369 379 # verify token is the correct role
370 380 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
371 381 log.debug('Got token with role:%s expected is %s',
372 382 getattr(token, 'role', 'EMPTY_TOKEN'),
373 383 UserApiKeys.ROLE_PASSWORD_RESET)
374 384 self.session.flash(
375 385 _('Given reset token is invalid'), queue='error')
376 386 return HTTPFound(self.request.route_path('reset_password'))
377 387
378 388 try:
379 389 owner = token.user
380 390 data = {'email': owner.email, 'token': token.api_key}
381 391 UserModel().reset_password(data)
382 392 self.session.flash(
383 393 _('Your password reset was successful, '
384 394 'a new password has been sent to your email'),
385 395 queue='success')
386 396 except Exception as e:
387 397 log.error(e)
388 398 return HTTPFound(self.request.route_path('reset_password'))
389 399
390 400 return HTTPFound(self.request.route_path('login'))
General Comments 0
You need to be logged in to leave comments. Login now