diff options
-rw-r--r-- | okupy/accounts/forms.py | 5 | ||||
-rw-r--r-- | okupy/accounts/views.py | 42 | ||||
-rw-r--r-- | okupy/common/crypto.py | 5 | ||||
-rw-r--r-- | okupy/common/models.py | 7 | ||||
-rw-r--r-- | okupy/otp/models.py | 10 | ||||
-rw-r--r-- | okupy/otp/sotp/models.py | 17 | ||||
-rw-r--r-- | okupy/otp/totp/models.py | 34 |
7 files changed, 99 insertions, 21 deletions
diff --git a/okupy/accounts/forms.py b/okupy/accounts/forms.py index 29c0248..a82bba9 100644 --- a/okupy/accounts/forms.py +++ b/okupy/accounts/forms.py @@ -11,6 +11,11 @@ class LoginForm(forms.Form): label='Password:') +class StrongAuthForm(forms.Form): + password = forms.CharField(max_length=30, widget=forms.PasswordInput(), + label='Password:') + + class OpenIDLoginForm(LoginForm): auto_logout = forms.BooleanField(required=False, label='Log out after answering the OpenID request') diff --git a/okupy/accounts/views.py b/okupy/accounts/views.py index dcfbadf..e245d43 100644 --- a/okupy/accounts/views.py +++ b/okupy/accounts/views.py @@ -29,7 +29,7 @@ from passlib.hash import ldap_md5_crypt from urlparse import urljoin, urlparse, parse_qsl from .forms import (LoginForm, OpenIDLoginForm, SSLCertLoginForm, - OTPForm, SignupForm, SiteAuthForm) + OTPForm, SignupForm, SiteAuthForm, StrongAuthForm) from .models import LDAPUser, OpenID_Attributes, Queue from .openid_store import DjangoDBOpenIDStore from ..common.ldap_helpers import (get_bound_ldapuser, @@ -40,6 +40,7 @@ from ..common.decorators import strong_auth_required from ..common.exceptions import OkupyError from ..common.log import log_extra_data from ..otp import init_otp +from ..otp.models import RevokedToken from ..otp.sotp.models import SOTPDevice from ..otp.totp.models import TOTPDevice @@ -84,9 +85,16 @@ def login(request): next = request.REQUEST.get('next') or reverse(index) is_otp = False login_form = None - login_form_class = OpenIDLoginForm if oreq else LoginForm strong_auth_req = 'strong_auth_requested' in request.session + if oreq: + login_form_class = OpenIDLoginForm + elif ('strong_auth_requested' in request.session + and request.user.is_authenticated()): + login_form_class = StrongAuthForm + else: + login_form_class = LoginForm + try: if request.method != 'POST': pass @@ -108,6 +116,9 @@ def login(request): else: raise OkupyError('OTP verification failed') + # prevent replay attacks and race conditions + if not RevokedToken.add(request.user, token): + raise OkupyError('OTP verification failed') dev = django_otp.match_token(request.user, token) if not dev: raise OkupyError('OTP verification failed') @@ -115,7 +126,10 @@ def login(request): else: login_form = login_form_class(request.POST) if login_form.is_valid(): - username = login_form.cleaned_data['username'] + if login_form_class != StrongAuthForm: + username = login_form.cleaned_data['username'] + else: + username = request.user.username password = login_form.cleaned_data['password'] else: raise OkupyError('Login failed') @@ -149,13 +163,17 @@ def login(request): logger.critical(error, extra=log_extra_data(request)) logger_mail.exception(error) raise OkupyError("Can't contact LDAP server") - if (request.user.is_authenticated() - and (not strong_auth_req - or 'secondary_password' in request.session)): - if request.user.is_verified(): - return redirect(next) - login_form = OTPForm() - is_otp = True + if request.user.is_authenticated(): + if (strong_auth_req + and not 'secondary_password' in request.session): + if request.method != 'POST': + messages.info(request, 'You need to type in your password' + + ' again to perform this action') + else: + if request.user.is_verified(): + return redirect(next) + login_form = OTPForm() + is_otp = True if login_form is None: login_form = login_form_class() @@ -370,6 +388,10 @@ def otp_setup(request): if not conf_form.is_valid(): raise OkupyError() token = conf_form.cleaned_data['otp_token'] + + # prevent reusing the same token to login + if not RevokedToken.add(request.user, token): + raise OkupyError() if not dev.verify_token(token, secret): raise OkupyError() except OkupyError: diff --git a/okupy/common/crypto.py b/okupy/common/crypto.py index 57694c0..2596680 100644 --- a/okupy/common/crypto.py +++ b/okupy/common/crypto.py @@ -51,6 +51,11 @@ cipher = OkupyCipher() class IDCipher(object): + """ + A cipher to create 'encrypted database IDs'. It is specifically fit + to encrypt an integer into constant-length hexstring. + """ + def encrypt(self, id): byte_id = struct.pack('!I', id) byte_eid = cipher.encrypt(byte_id) diff --git a/okupy/common/models.py b/okupy/common/models.py index d1ef128..4a7ee73 100644 --- a/okupy/common/models.py +++ b/okupy/common/models.py @@ -17,10 +17,17 @@ class EncryptedPKModelManager(models.Manager): class EncryptedPKModel(models.Model): + """ + A model with built-in identifier encryption (for secure tokens). + """ + objects = EncryptedPKModelManager() @property def encrypted_id(self): + """ + The object identifier encrypted using IDCipher, as a hex-string. + """ if self.id is None: return None return idcipher.encrypt(self.id) diff --git a/okupy/otp/models.py b/okupy/otp/models.py index f3595cd..a43fb4e 100644 --- a/okupy/otp/models.py +++ b/okupy/otp/models.py @@ -15,6 +15,10 @@ class RevokedToken(models.Model): @classmethod def cleanup(cls): + """ + Remove tokens old enough to be no longer valid. + """ + # we use this just to enforce atomicity and prevent replay # for SOTP, we can clean up old tokens quite fast # (as soon as .delete() is effective) @@ -24,6 +28,12 @@ class RevokedToken(models.Model): @classmethod def add(cls, user, token): + """ + Use and revoke the given token, for the given user. + + Returns True if the token is fine, False if it was used + already. + """ cls.cleanup() t = cls(user=user, token=token) diff --git a/okupy/otp/sotp/models.py b/okupy/otp/sotp/models.py index 82949d2..5bb58f8 100644 --- a/okupy/otp/sotp/models.py +++ b/okupy/otp/sotp/models.py @@ -3,13 +3,21 @@ from django_otp.models import Device from ...accounts.models import LDAPUser -from ..models import RevokedToken import random class SOTPDevice(Device): + """ + OTP device that verifies against a list of recovery keys in LDAP. + """ + def gen_keys(self, user, num=10): + """ + Generate new recovery keys for user and store them in LDAP. + + Previous keys (if any) will be removed. + """ new_keys = set() # generate the new keys the fun way @@ -23,10 +31,9 @@ class SOTPDevice(Device): return new_keys def verify_token(self, token): - # ensure atomic revocation - if not RevokedToken.add(self.user, token): - return False - + """ + Verify token against recovery keys. + """ u = LDAPUser.objects.get(username = self.user.username) if token in u.otp_recovery_keys: u.otp_recovery_keys.remove(token) diff --git a/okupy/otp/totp/models.py b/okupy/otp/totp/models.py index d61d17d..a32baa6 100644 --- a/okupy/otp/totp/models.py +++ b/okupy/otp/totp/models.py @@ -6,33 +6,59 @@ from django_otp.models import Device from base64 import b32decode, b32encode from ...accounts.models import LDAPUser -from ..models import RevokedToken import Crypto.Random class TOTPDevice(Device): + """ + OTP device that verifies against a TOTP-generated token. + """ + def is_enabled(self): + """ + Check whether TOTP is enabled. + + Returns True if user has TOTP secret set, False otherwise. + """ return not self.verify_token() def disable(self, user): + """ + Disable TOTP. Removes the secret from LDAP. + """ if user.otp_secret: user.otp_secret = None user.save() def enable(self, user, new_secret): + """ + Enable TOTP. Saves the secret to LDAP. + """ user.otp_secret = new_secret user.save() def gen_secret(self): + """ + Generate a new TOTP secret compliant with Google Authenticator. + + Returns 20-character base32 string (with padding stripped). + """ rng = Crypto.Random.new() return b32encode(rng.read(12)).rstrip('=') @staticmethod def get_uri(secret): - return 'otpauth://totp/identity.gentoo.org?secret=%s' % secret + """ + Get otpauth:// URI for secret transfer. + """ + return 'otpauth://totp/gentoo.org?secret=%s' % secret def verify_token(self, token=None, secret=None): + """ + Verify the entered token against current TOTP token, and the few + past and future tokens to include clock drift. + """ if not secret: u = LDAPUser.objects.get(username = self.user.username) if not u.otp_secret: @@ -41,10 +67,6 @@ class TOTPDevice(Device): return False secret = u.otp_secret - # prevent replay attacks - if not RevokedToken.add(self.user, token): - return False - # add missing padding if necessary secret += '=' * (-len(secret) % 8) |