├── .gitignore ├── LICENSE ├── README.mkd ├── password_required ├── __init__.py ├── decorators.py ├── forms.py ├── locale │ └── da │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── models.py ├── templates │ └── password_required_login.html ├── test_urls.py ├── tests.py └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Mikkel Høgh 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /README.mkd: -------------------------------------------------------------------------------- 1 | Django Password Required 2 | ======================== 3 | 4 | A reusable [Django][] app for requiring a password to access some pages on 5 | a site, without requiring users to register an account. 6 | 7 | This is a very simple method of authentication, and is intended to 8 | provide a low barrier of entry to a site that is not completely public. 9 | 10 | Use cases could be previews of sites that users are not supposed to log 11 | in to, Stack Overflow-style beta tests, etc. 12 | 13 | Using the `@password_required` decorator, you can password-protect 14 | individual views. 15 | 16 | This module is based on simple session variables, and thus interoperable 17 | with `django.contrib.auth`, allowing you to optionally bypass password 18 | protection for all authenticated users, or only for certain groups. 19 | 20 | Usage 21 | ----- 22 | 23 | 0. Install the app. 24 | Not really within the scope of this document, but if you have [pip][] 25 | installed, you could do something like this: 26 | 27 | `pip install -e git+git://github.com/mikl/django-password-required.git#egg=password_required` 28 | 29 | If you are developing multiple Django sites, you should probably use 30 | [virtualenv][] to keep their dependencies separate. 31 | 32 | 1. Add `password_required` to `INSTALLED_APPS` in your 33 | [Django settings file][settings]. 34 | 35 | 2. Set `PASSWORD_REQUIRED_PASSWORD` to the preferred password in your 36 | Django settings file. Example: 37 | 38 | `PASSWORD_REQUIRED_PASSWORD = 'mysecretpassword'` 39 | 40 | 3. Add the password required login page to your [URLconf][]. Example: 41 | 42 | ``` 43 | import password_required.views 44 | url(r'^password_required/$', password_required.views.login), 45 | ``` 46 | 47 | 4. Apply the `@password_required` decorator to your views like in this 48 | final code example. 49 | 50 | 51 | from password_required.decorators import password_required 52 | [...more imports...] 53 | 54 | @password_required 55 | def my_awesome_view(request): 56 | [...view code here...] 57 | 58 | 59 | Requirements 60 | ------------ 61 | 62 | This app requires Python 3 and Django 1.5 or later. 63 | 64 | 65 | License 66 | ------- 67 | 68 | This app is [BSD-licensed][BSD], just like Django. 69 | 70 | 71 | Development, support and feedback 72 | --------------------------------- 73 | 74 | If you have problems, find a bug or have other feedback, please file an 75 | issue on the [main Github repo][repo]. 76 | 77 | [Django]: http://www.djangoproject.com/ 78 | [pip]: http://pip.openplans.org/ 79 | [virtualenv]: http://pypi.python.org/pypi/virtualenv 80 | [settings]: http://docs.djangoproject.com/en/1.5/topics/settings/ 81 | [URLconf]: http://docs.djangoproject.com/en/1.5/topics/http/urls/#topics-http-urls 82 | [BSD]: http://www.opensource.org/licenses/bsd-license.php 83 | [repo]: http://github.com/mikl/django-password-required 84 | 85 | -------------------------------------------------------------------------------- /password_required/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Mikkel Høgh' 3 | __version__ = (0, 1, 0) 4 | 5 | -------------------------------------------------------------------------------- /password_required/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ The password_required decorator for Django views """ 3 | from functools import update_wrapper, wraps 4 | from django.contrib.auth import REDIRECT_FIELD_NAME 5 | from django.urls import reverse 6 | from django.http import HttpResponseRedirect 7 | from django.utils.http import urlquote 8 | 9 | def password_required(view_func=None, redirect_field_name=REDIRECT_FIELD_NAME): 10 | """ 11 | Decorator for views that checks that the user has entered the password, 12 | redirecting to the log-in page if necessary. 13 | """ 14 | def _wrapped_view(request, *args, **kwargs): 15 | if request.session.get('password_required_auth', False): 16 | return view_func(request, *args, **kwargs) 17 | 18 | return HttpResponseRedirect('%s?%s=%s' % ( 19 | reverse('password_required.views.login'), 20 | redirect_field_name, 21 | urlquote(request.get_full_path()), 22 | )) 23 | return wraps(view_func)(_wrapped_view) 24 | 25 | -------------------------------------------------------------------------------- /password_required/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import forms 3 | from django.conf import settings 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | class AuthenticationForm(forms.Form): 7 | """ 8 | Simple form to allow users to access a page via a password. 9 | 10 | A copy of django.contrib.auth.forms.AuthenticationForm, adapted to this 11 | much simpler use case. 12 | """ 13 | password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) 14 | 15 | def __init__(self, request=None, *args, **kwargs): 16 | """ 17 | If request is passed in, the form will validate that cookies are 18 | enabled. Note that the request (a HttpRequest object) must have set a 19 | cookie with the key TEST_COOKIE_NAME and value TEST_COOKIE_VALUE before 20 | running this validation. 21 | """ 22 | self.request = request 23 | super(AuthenticationForm, self).__init__(*args, **kwargs) 24 | 25 | 26 | def clean_password(self): 27 | """ 28 | Validate that the password entered was correct. 29 | """ 30 | password = self.cleaned_data.get('password') 31 | correct_password = getattr(settings, 'PASSWORD_REQUIRED_PASSWORD', None) 32 | 33 | if not correct_password: 34 | raise forms.ValidationError(_("PASSWORD_REQUIRED_PASSWORD is not set, and thus it is currently impossible to log in.")) 35 | 36 | if not (password == correct_password or 37 | password.strip() == correct_password): 38 | raise forms.ValidationError(_("Please enter the correct password. Note that the password is case-sensitive.")) 39 | 40 | # TODO: determine whether this should move to its own method. 41 | if self.request: 42 | if not self.request.session.test_cookie_worked(): 43 | raise forms.ValidationError(_("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.")) 44 | 45 | return self.cleaned_data 46 | 47 | -------------------------------------------------------------------------------- /password_required/locale/da/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikl/django-password-required/2388d8b7de345b75841e25d38ccd024c861617ef/password_required/locale/da/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /password_required/locale/da/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 1.0.0\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2010-06-07 09:11+0200\n" 12 | "PO-Revision-Date: 2010-06-07 09:13+0200\n" 13 | "Last-Translator: Mikkel Høgh \n" 14 | "Language-Team: Dansk\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | 19 | #: forms.py:13 20 | msgid "Password" 21 | msgstr "Kodeord" 22 | 23 | #: forms.py:34 tests.py:33 24 | msgid "" 25 | "PASSWORD_REQUIRED_PASSWORD is not set, and thus it is currently impossible " 26 | "to log in." 27 | msgstr "PASSWORD_REQUIRED_PASSWORD er ikke sat, og det er derfor pt. ikke muligt at logge ind." 28 | 29 | #: forms.py:38 30 | msgid "" 31 | "Please enter the correct password. Note that the password is case-sensitive." 32 | msgstr "Skriv venligst det korrekte kodeord. Bemærk at der gøres forskel på store og små bogstaver." 33 | 34 | #: forms.py:43 35 | msgid "" 36 | "Your Web browser doesn't appear to have cookies enabled. Cookies are " 37 | "required for logging in." 38 | msgstr "Din Internetbrwoser ser ikke ud til at have cookies slået til. Uden adgang til cookies, kan vi ikke logge dig ind." 39 | 40 | #: templates/password_required_login.html:4 41 | msgid "Let me in" 42 | msgstr "Luk mig ind" 43 | -------------------------------------------------------------------------------- /password_required/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ The mandatory models.py to make Django consider this to be an app. """ 3 | from django.db import models 4 | 5 | -------------------------------------------------------------------------------- /password_required/templates/password_required_login.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
{% csrf_token %} 3 | {{ form.as_p }} 4 | 5 |
6 | 7 | -------------------------------------------------------------------------------- /password_required/test_urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls.defaults import * 3 | 4 | urlpatterns = patterns('', 5 | (r'^password_required/$', 'password_required.views.login'), 6 | 7 | # We just need a 200 response code, never mind that the template 8 | # produces no output without a context. 9 | (r'^test/$', 'django.views.generic.simple.direct_to_template', { 10 | 'template': 'password_required_login.html', 11 | }), 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /password_required/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ Unittests for password_required app. """ 3 | 4 | from django.conf import settings 5 | from django.contrib.auth import REDIRECT_FIELD_NAME 6 | from django.urls import reverse 7 | from django.test import TestCase 8 | from django.utils.translation import ugettext_lazy as _ 9 | 10 | class MissingConfTest(TestCase): 11 | """ Tests for correct behavior on missing configuration. """ 12 | urls = 'password_required.test_urls' 13 | 14 | def setUp(self): 15 | """ Configure the testcase """ 16 | self.login_url = reverse('password_required.views.login') 17 | 18 | # Unset the password if set. 19 | try: 20 | delattr(settings, 'PASSWORD_REQUIRED_PASSWORD') 21 | except AttributeError: 22 | print 'durrr' 23 | pass 24 | 25 | def test_missing_password_fail(self): 26 | """ Test that the login form fails when the password is not set. """ 27 | response = self.client.post(self.login_url, { 28 | REDIRECT_FIELD_NAME: '/test/', 29 | 'password': u'prøve' 30 | }) 31 | 32 | self.assertFormError(response, 'form', 'password', 33 | _("PASSWORD_REQUIRED_PASSWORD is not set, and thus it is currently impossible to log in.")) 34 | 35 | class LoginViewTest(TestCase): 36 | """ Tests for the login view """ 37 | urls = 'password_required.test_urls' 38 | 39 | def setUp(self): 40 | """ Configure the testcase """ 41 | self.login_url = reverse('password_required.views.login') 42 | settings.PASSWORD_REQUIRED_PASSWORD = u'prøve' 43 | 44 | def test_form_display(self): 45 | """ Test that the login form is rendered. """ 46 | response = self.client.get(self.login_url, { 47 | REDIRECT_FIELD_NAME: '/test/', 48 | }) 49 | self.assertTemplateUsed(response, 50 | template_name='password_required_login.html') 51 | 52 | def test_successful_login(self): 53 | """ 54 | Test that the login page works. 55 | """ 56 | response = self.client.post(self.login_url, { 57 | REDIRECT_FIELD_NAME: '/test/', 58 | 'password': u'prøve' 59 | }) 60 | 61 | self.assertRedirects(response, '/test/') 62 | 63 | # Visiting the login page after successfully logging in, should 64 | # cause an immediate redirect. 65 | response = self.client.get(self.login_url, { 66 | REDIRECT_FIELD_NAME: '/test/', 67 | }) 68 | self.assertRedirects(response, '/test/') 69 | 70 | -------------------------------------------------------------------------------- /password_required/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.contrib.auth import REDIRECT_FIELD_NAME 4 | from django.contrib.sites.models import Site 5 | from django.contrib.sites.requests import RequestSite 6 | from django.http import HttpResponseRedirect 7 | from django.shortcuts import render 8 | from django.views.decorators.cache import never_cache 9 | from django.views.decorators.csrf import csrf_protect 10 | from password_required.forms import AuthenticationForm 11 | import re 12 | 13 | @csrf_protect 14 | @never_cache 15 | def login(request, template_name='password_required_login.html', 16 | redirect_field_name=REDIRECT_FIELD_NAME, 17 | authentication_form=AuthenticationForm): 18 | """Displays the login form and handles the login action.""" 19 | redirect_to = _clean_redirect(request.GET.get(redirect_field_name, '')) 20 | 21 | # If the user is already logged in, redirect him immediately. 22 | if request.session.get('password_required_auth', False): 23 | return HttpResponseRedirect(redirect_to) 24 | 25 | if request.method == "POST": 26 | form = authentication_form(data=request.POST) 27 | if form.is_valid(): 28 | # Mark the user as logged in via his session data. 29 | request.session['password_required_auth'] = True 30 | 31 | if request.session.test_cookie_worked(): 32 | request.session.delete_test_cookie() 33 | 34 | return HttpResponseRedirect(redirect_to) 35 | 36 | else: 37 | form = authentication_form(request) 38 | 39 | request.session.set_test_cookie() 40 | 41 | if Site._meta.installed: 42 | current_site = Site.objects.get_current() 43 | else: 44 | current_site = RequestSite(request) 45 | 46 | return render(request, template_name, { 47 | 'form': form, 48 | redirect_field_name: redirect_to, 49 | 'site': current_site, 50 | 'site_name': current_site.name, 51 | }) 52 | 53 | def _clean_redirect(redirect_to): 54 | """ 55 | Perform a few security checks on the redirect destination. 56 | 57 | Copied from django.contrib.auth.views.login. It really should be split 58 | out from that. 59 | """ 60 | # Light security check -- make sure redirect_to isn't garbage. 61 | if not redirect_to or ' ' in redirect_to: 62 | redirect_to = settings.LOGIN_REDIRECT_URL 63 | 64 | # Heavier security check -- redirects to http://example.com should 65 | # not be allowed, but things like /view/?param=http://example.com 66 | # should be allowed. This regex checks if there is a '//' *before* a 67 | # question mark. 68 | elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to): 69 | redirect_to = settings.LOGIN_REDIRECT_URL 70 | 71 | return redirect_to 72 | 73 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from distutils.core import setup 4 | 5 | setup( 6 | name='django-password-required', 7 | version='0.1.0', 8 | description='A flexible & capable API layer for Django.', 9 | author='Mikkel Høgh', 10 | author_email='mikkel@hoegh.org', 11 | url='http://github.com/mikl/django-password-required/', 12 | packages=[ 13 | 'password_required', 14 | ], 15 | package_data={ 16 | 'password_required': ['templates/*'], 17 | }, 18 | classifiers=[ 19 | 'Development Status :: 3 - Alpha', 20 | 'Environment :: Web Environment', 21 | 'Framework :: Django', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Topic :: Utilities' 27 | ], 28 | ) 29 | 30 | --------------------------------------------------------------------------------