2 Waldo provides abstract :class:`.MultiView`\ s to handle several levels of common authentication:
4 * :class:`LoginMultiView` handles the case where users only need to be able to log in and out.
5 * :class:`PasswordMultiView` handles the case where users will also need to change their password.
6 * :class:`RegistrationMultiView` builds on top of :class:`PasswordMultiView` to handle user registration, as well.
7 * :class:`AccountMultiView` adds account-handling functionality to the :class:`RegistrationMultiView`.
13 from django import forms
14 from django.conf.urls.defaults import url, patterns, include
15 from django.contrib import messages
16 from django.contrib.auth import authenticate, login, views as auth_views
17 from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm
18 from django.contrib.auth.models import User
19 from django.contrib.auth.tokens import default_token_generator as password_token_generator
20 from django.contrib.sites.models import Site
21 from django.core.mail import EmailMultiAlternatives, send_mail
22 from django.db import models
23 from django.http import Http404, HttpResponseRedirect
24 from django.shortcuts import render_to_response, get_object_or_404
25 from django.template.defaultfilters import striptags
26 from django.utils.http import int_to_base36, base36_to_int
27 from django.utils.translation import ugettext as _
28 from django.views.decorators.cache import never_cache
29 from django.views.decorators.csrf import csrf_protect
31 from philo.models import MultiView, Page
32 from philo.contrib.waldo.forms import WaldoAuthenticationForm, RegistrationForm, UserAccountForm
33 from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
36 class LoginMultiView(MultiView):
37 """Handles exclusively methods and views related to logging users in and out."""
38 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the login form.
39 login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
40 #: A django form class which will be used for the authentication process. Default: :class:`.WaldoAuthenticationForm`.
41 login_form = WaldoAuthenticationForm
44 def urlpatterns(self):
46 url(r'^login$', self.login, name='login'),
47 url(r'^logout$', self.logout, name='logout'),
50 def set_requirement_redirect(self, request, redirect=None):
51 """Figures out and stores where a user should end up after landing on a page (like the login page) because they have not fulfilled some kind of requirement."""
52 if redirect is not None:
54 elif 'requirement_redirect' in request.session:
57 referrer = request.META.get('HTTP_REFERER', None)
59 if referrer is not None:
60 referrer = urlparse.urlparse(referrer)
62 if host != request.get_host():
65 redirect = '%s?%s' % (referrer[2], referrer[4])
67 path = request.get_full_path()
68 if referrer is None or redirect == path:
69 # Default to the index page if we can't find a referrer or
70 # if we'd otherwise redirect to where we already are.
71 redirect = request.node.get_absolute_url()
73 request.session['requirement_redirect'] = redirect
75 def get_requirement_redirect(self, request, default=None):
76 """Returns the location which a user should be redirected to after fulfilling a requirement (like logging in)."""
77 redirect = request.session.pop('requirement_redirect', None)
78 # Security checks a la django.contrib.auth.views.login
79 if not redirect or ' ' in redirect:
82 netloc = urlparse.urlparse(redirect)[1]
83 if netloc and netloc != request.get_host():
86 redirect = request.node.get_absolute_url()
90 def login(self, request, extra_context=None):
91 """Renders the :attr:`login_page` with an instance of the :attr:`login_form` for the given :class:`HttpRequest`."""
92 self.set_requirement_redirect(request)
94 # Redirect already-authenticated users to the index page.
95 if request.user.is_authenticated():
96 messages.add_message(request, messages.INFO, "You are already authenticated. Please log out if you wish to log in as a different user.")
97 return HttpResponseRedirect(self.get_requirement_redirect(request))
99 if request.method == 'POST':
100 form = self.login_form(request=request, data=request.POST)
102 redirect = self.get_requirement_redirect(request)
103 login(request, form.get_user())
105 if request.session.test_cookie_worked():
106 request.session.delete_test_cookie()
108 return HttpResponseRedirect(redirect)
110 form = self.login_form()
112 request.session.set_test_cookie()
114 context = self.get_context()
115 context.update(extra_context or {})
119 return self.login_page.render_to_response(request, extra_context=context)
122 def logout(self, request, extra_context=None):
123 """Logs the given :class:`HttpRequest` out, redirecting the user to the page they just left or to the :meth:`~.Node.get_absolute_url` for the ``request.node``."""
124 return auth_views.logout(request, request.META.get('HTTP_REFERER', request.node.get_absolute_url()))
126 def login_required(self, view):
127 """Wraps a view function to require that the user be logged in."""
128 def inner(request, *args, **kwargs):
129 if not request.user.is_authenticated():
130 self.set_requirement_redirect(request, redirect=request.path)
132 messages.add_message(request, messages.ERROR, "Please log in again, because your session has expired.")
133 return HttpResponseRedirect(self.reverse('login', node=request.node))
134 return view(request, *args, **kwargs)
142 class PasswordMultiView(LoginMultiView):
144 Adds support for password setting, resetting, and changing to the :class:`LoginMultiView`. Password reset support includes handling of a confirmation email.
147 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset request form.
148 password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related', blank=True, null=True)
149 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset confirmation email.
150 password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related', blank=True, null=True)
151 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password setting form (i.e. the page that users will see after confirming a password reset).
152 password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related', blank=True, null=True)
153 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password change form.
154 password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
156 #: The password change form class. Default: :class:`django.contrib.auth.forms.PasswordChangeForm`.
157 password_change_form = PasswordChangeForm
158 #: The password set form class. Default: :class:`django.contrib.auth.forms.SetPasswordForm`.
159 password_set_form = SetPasswordForm
160 #: The password reset request form class. Default: :class:`django.contrib.auth.forms.PasswordResetForm`.
161 password_reset_form = PasswordResetForm
164 def urlpatterns(self):
165 urlpatterns = super(PasswordMultiView, self).urlpatterns
167 if self.password_reset_page_id and self.password_reset_confirmation_email_id and self.password_set_page_id:
168 urlpatterns += patterns('',
169 url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'),
170 url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'),
173 if self.password_change_page_id:
174 urlpatterns += patterns('',
175 url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
179 def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None, secure=False):
181 Generates a confirmation link for an arbitrary action, such as a password reset.
183 :param confirmation_view: The view function which needs to be linked to.
184 :param token_generator: Generates a confirmable token for the action.
185 :param user: The user who is trying to take the action.
186 :param node: The node which is providing the basis for the confirmation URL.
187 :param token_args: A list of additional arguments (i.e. besides the user) to be used for token creation.
188 :param reverse_kwargs: A dictionary of any additional keyword arguments necessary for correctly reversing the view.
189 :param secure: Whether the link should use the https:// or http://.
192 token = token_generator.make_token(user, *(token_args or []))
194 'uidb36': int_to_base36(user.id),
197 kwargs.update(reverse_kwargs or {})
198 return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True, secure=secure)
200 def send_confirmation_email(self, subject, email, page, extra_context):
202 Sends a confirmation email for an arbitrary action, such as a password reset. If the ``page``'s :class:`.Template` has a mimetype of ``text/html``, then the email will be sent with an HTML alternative version.
204 :param subject: The subject line of the email.
205 :param email: The recipient's address.
206 :param page: The page which will be used to render the email body.
207 :param extra_context: The context for rendering the ``page``.
210 text_content = page.render_to_string(extra_context=extra_context)
211 from_email = 'noreply@%s' % Site.objects.get_current().domain
213 if page.template.mimetype == 'text/html':
214 msg = EmailMultiAlternatives(subject, striptags(text_content), from_email, [email])
215 msg.attach_alternative(text_content, 'text/html')
218 send_mail(subject, text_content, from_email, [email])
220 def password_reset(self, request, extra_context=None, token_generator=password_token_generator):
222 Handles the process by which users request a password reset, and generates the context for the confirmation email. That context will contain:
225 The confirmation link for the password reset.
227 The user requesting the reset.
229 The current :class:`Site`.
231 The current :class:`HttpRequest` instance.
233 :param token_generator: The token generator to use for the confirmation link.
236 if request.user.is_authenticated():
237 return HttpResponseRedirect(request.node.get_absolute_url())
239 if request.method == 'POST':
240 form = self.password_reset_form(request.POST)
242 current_site = Site.objects.get_current()
243 for user in form.users_cache:
245 'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node, secure=request.is_secure()),
247 'site': current_site,
250 self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
251 messages.add_message(request, messages.SUCCESS, "An email has been sent to the address you provided with details on resetting your password.", fail_silently=True)
252 return HttpResponseRedirect('')
254 form = self.password_reset_form()
256 context = self.get_context()
257 context.update(extra_context or {})
261 return self.password_reset_page.render_to_response(request, extra_context=context)
263 def password_reset_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=password_token_generator):
265 Checks that ``token``` is valid, and if so, renders an instance of :attr:`password_set_form` with :attr:`password_set_page`.
267 :param token_generator: The token generator used to check the ``token``.
270 assert uidb36 is not None and token is not None
272 uid_int = base36_to_int(uidb36)
276 user = get_object_or_404(User, id=uid_int)
278 if token_generator.check_token(user, token):
279 if request.method == 'POST':
280 form = self.password_set_form(user, request.POST)
284 messages.add_message(request, messages.SUCCESS, "Password reset successful.")
285 return HttpResponseRedirect(self.reverse('login', node=request.node))
287 form = self.password_set_form(user)
289 context = self.get_context()
290 context.update(extra_context or {})
294 return self.password_set_page.render_to_response(request, extra_context=context)
298 def password_change(self, request, extra_context=None):
299 """Renders an instance of :attr:`password_change_form` with :attr:`password_change_page`."""
300 if request.method == 'POST':
301 form = self.password_change_form(request.user, request.POST)
304 messages.add_message(request, messages.SUCCESS, 'Password changed successfully.', fail_silently=True)
305 return HttpResponseRedirect('')
307 form = self.password_change_form(request.user)
309 context = self.get_context()
310 context.update(extra_context or {})
314 return self.password_change_page.render_to_response(request, extra_context=context)
320 class RegistrationMultiView(PasswordMultiView):
321 """Adds support for user registration to the :class:`PasswordMultiView`."""
322 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to display the registration form.
323 register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related', blank=True, null=True)
324 #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the registration confirmation email.
325 register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related', blank=True, null=True)
326 #: The registration form class. Default: :class:`.RegistrationForm`.
327 registration_form = RegistrationForm
330 def urlpatterns(self):
331 urlpatterns = super(RegistrationMultiView, self).urlpatterns
332 if self.register_page_id and self.register_confirmation_email_id:
333 urlpatterns += patterns('',
334 url(r'^register$', csrf_protect(self.register), name='register'),
335 url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.register_confirm, name='register_confirm')
339 def register(self, request, extra_context=None, token_generator=registration_token_generator):
341 Renders the :attr:`register_page` with an instance of :attr:`registration_form` in the context as ``form``. If the form has been submitted, sends a confirmation email using :attr:`register_confirmation_email` and the same context as :meth:`PasswordMultiView.password_reset`.
343 :param token_generator: The token generator to use for the confirmation link.
346 if request.user.is_authenticated():
347 return HttpResponseRedirect(request.node.get_absolute_url())
349 if request.method == 'POST':
350 form = self.registration_form(request.POST)
353 current_site = Site.objects.get_current()
355 'link': self.make_confirmation_link('register_confirm', token_generator, user, request.node, secure=request.is_secure()),
357 'site': current_site,
360 self.send_confirmation_email('Confirm account creation at %s' % current_site.name, user.email, self.register_confirmation_email, context)
361 messages.add_message(request, messages.SUCCESS, 'An email has been sent to %s with details on activating your account.' % user.email, fail_silently=True)
362 return HttpResponseRedirect(request.node.get_absolute_url())
364 form = self.registration_form()
366 context = self.get_context()
367 context.update(extra_context or {})
371 return self.register_page.render_to_response(request, extra_context=context)
373 def register_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=registration_token_generator):
375 Checks that ``token`` is valid, and if so, logs the user in and redirects them to :meth:`post_register_confirm_redirect`.
377 :param token_generator: The token generator used to check the ``token``.
379 assert uidb36 is not None and token is not None
381 uid_int = base36_to_int(uidb36)
385 user = get_object_or_404(User, id=uid_int)
386 if token_generator.check_token(user, token):
387 user.is_active = True
388 true_password = user.password
389 temp_password = token_generator.make_token(user)
391 user.set_password(temp_password)
393 authenticated_user = authenticate(username=user.username, password=temp_password)
394 login(request, authenticated_user)
396 # if anything goes wrong, do our best make sure that the true password is restored.
397 user.password = true_password
399 return self.post_register_confirm_redirect(request)
403 def post_register_confirm_redirect(self, request):
404 """Returns an :class:`HttpResponseRedirect` for post-registration-confirmation. Default: :meth:`Node.get_absolute_url` for ``request.node``."""
405 return HttpResponseRedirect(request.node.get_absolute_url())
411 class AccountMultiView(RegistrationMultiView):
412 """Adds support for user accounts on top of the :class:`RegistrationMultiView`. By default, the account consists of the first_name, last_name, and email fields of the User model. Using a different account model is as simple as replacing :attr:`account_form` with any form class that takes an :class:`auth.User` instance as the first argument."""
413 #: A :class:`ForeignKey` to the :class:`Page` which will be used to render the account management form.
414 manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related', blank=True, null=True)
415 #: A :class:`ForeignKey` to a :class:`Page` which will be used to render an email change confirmation email. This is optional; if it is left blank, then email changes will be performed without confirmation.
416 email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related', blank=True, null=True, help_text="If this is left blank, email changes will be performed without confirmation.")
418 #: A django form class which will be used to manage the user's account. Default: :class:`.UserAccountForm`
419 account_form = UserAccountForm
422 def urlpatterns(self):
423 urlpatterns = super(AccountMultiView, self).urlpatterns
424 if self.manage_account_page_id:
425 urlpatterns += patterns('',
426 url(r'^account$', self.login_required(self.account_view), name='account'),
428 if self.email_change_confirmation_email_id:
429 urlpatterns += patterns('',
430 url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)$', self.email_change_confirm, name='email_change_confirm')
434 def account_view(self, request, extra_context=None, token_generator=email_token_generator, *args, **kwargs):
436 Renders the :attr:`manage_account_page` with an instance of :attr:`account_form` in the context as ``form``. If the form has been posted, the user's email was changed, and :attr:`email_change_confirmation_email` is not ``None``, sends a confirmation email to the new email to make sure it exists before making the change. The email will have the same context as :meth:`PasswordMultiView.password_reset`.
438 :param token_generator: The token generator to use for the confirmation link.
441 if request.method == 'POST':
442 form = self.account_form(request.user, request.POST, request.FILES)
445 message = "Account information saved."
446 redirect = self.get_requirement_redirect(request, default='')
447 if 'email' in form.changed_data and self.email_change_confirmation_email:
448 # ModelForms modify their instances in-place during
449 # validation, so reset the instance's email to its
450 # previous value here, then remove the new value
451 # from cleaned_data. We only do this if an email
452 # change confirmation email is available.
453 request.user.email = form.initial['email']
455 email = form.cleaned_data.pop('email')
457 current_site = Site.objects.get_current()
460 'link': self.make_confirmation_link('email_change_confirm', token_generator, request.user, request.node, token_args=[email], reverse_kwargs={'email': email.replace('@', '+')}, secure=request.is_secure()),
461 'user': request.user,
462 'site': current_site,
465 self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context)
467 message = "An email has be sent to %s to confirm the email%s." % (email, bool(request.user.email) and " change" or "")
468 if not request.user.email:
469 message += " You will need to confirm the email before accessing pages that require a valid account."
475 message += " Here you go!"
477 messages.add_message(request, messages.SUCCESS, message, fail_silently=True)
478 return HttpResponseRedirect(redirect)
480 form = self.account_form(request.user)
482 context = self.get_context()
483 context.update(extra_context or {})
487 return self.manage_account_page.render_to_response(request, extra_context=context)
489 def has_valid_account(self, user):
490 """Returns ``True`` if the ``user`` has a valid account and ``False`` otherwise."""
491 form = self.account_form(user, {})
492 form.data = form.initial
493 return form.is_valid()
495 def account_required(self, view):
496 """Wraps a view function to allow access only to users with valid accounts and otherwise redirect them to the :meth:`account_view`."""
497 def inner(request, *args, **kwargs):
498 if not self.has_valid_account(request.user):
499 messages.add_message(request, messages.ERROR, "You need to add some account information before you can access that page.", fail_silently=True)
500 if self.manage_account_page:
501 self.set_requirement_redirect(request, redirect=request.path)
502 redirect = self.reverse('account', node=request.node)
504 redirect = request.node.get_absolute_url()
505 return HttpResponseRedirect(redirect)
506 return view(request, *args, **kwargs)
508 inner = self.login_required(inner)
511 def post_register_confirm_redirect(self, request):
512 """Automatically redirects users to the :meth:`account_view` after registration."""
513 if self.manage_account_page:
514 messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
515 return HttpResponseRedirect(self.reverse('account', node=request.node))
516 return super(AccountMultiView, self).post_register_confirm_redirect(request)
518 def email_change_confirm(self, request, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
520 Checks that ``token`` is valid, and if so, changes the user's email.
522 :param token_generator: The token generator used to check the ``token``.
525 assert uidb36 is not None and token is not None and email is not None
528 uid_int = base36_to_int(uidb36)
532 user = get_object_or_404(User, id=uid_int)
534 email = '@'.join(email.rsplit('+', 1))
536 if email == user.email:
537 # Then short-circuit.
540 if token_generator.check_token(user, email, token):
543 messages.add_message(request, messages.SUCCESS, 'Email changed successfully.')
544 if self.manage_account_page:
545 redirect = self.reverse('account', node=request.node)
547 redirect = request.node.get_absolute_url()
548 return HttpResponseRedirect(redirect)