def post(self, request: HttpRequest): """ Set the new secret """ form = NewOtpSecretForm(request.POST) if not form.is_valid(): return self._send_otp_form(request, form) secret = request.session.get('encrypted_new_totp_secret', None) if secret is None: form.add_error( None, _('No Secret set. Please create a new secret first.')) return self._send_otp_form(request, form) secret = SymmetricCrypt().decrypt(b64decode(secret.encode('us-ascii'))) if form.cleaned_data['otp_confirm'] not in get_possible_otps(secret): form.add_error('otp_confirm', _('One-time password invalid')) return self._send_otp_form(request, form) del request.session['encrypted_new_totp_secret'] try: user = HubUser.objects.get(id=request.user.id) user.set_totp_secret(secret) user.save() except DatabaseError: # pragma: no cover # Database Safeguard form.add_error( None, _('We are currently unable to update your OTP Secret. Please try again later.' )) return self._send_otp_form(request, form) return self._send_otp_form(request, success=True)
def test_encrypted_secret_generator_default_length(self): """ Test if a encrypted random secret has the required length """ self.assertEqual( 72, len(SymmetricCrypt().decrypt( create_encrypted_random_totp_secret())))
def _check_new_otp(self, link: str): """ Check the new OTP setup """ extracted_link = self.__extract_link(link) user_id = PendingCredentialRecovery.objects.get(uuid=extracted_link['uuid']).user.id self.__open_action(extracted_link) # The bad ones test_items = ( 'doctored-auth', '' ) for auth in test_items: self.client.post( '/hub/auth/forgot-credentials/step-3/{}/reveal-new-otp-secret'.format(extracted_link['uuid']), data={ 'auth': auth, }, follow=True ) otp_ready_response = self.client.post( '/hub/auth/forgot-credentials/step-3/{}/reveal-new-otp-secret'.format(extracted_link['uuid']), data={ 'auth': extracted_link['auth'], }, follow=True ) self.assertEqual(200, otp_ready_response.status_code) internal_session_data = str(b64decode(self.client.session.model.objects.first().session_data).decode('utf-8')) internal_session_data = internal_session_data[internal_session_data.index(':')+1:] internal_session_data = loads(internal_session_data) encrypted_otp_secret = b64decode(internal_session_data['encrypted_temporary_otp_secret']) new_otp_secret = SymmetricCrypt().decrypt(encrypted_otp_secret) # And the bad OTPs fist test_items = ( # auth, otp (extracted_link['auth'], ''), (extracted_link['auth'], '999999'), ) for auth, otp in test_items: self.client.post( '/hub/auth/forgot-credentials/step-3/{}/reveal-new-otp-secret/confirm'.format(extracted_link['uuid']), data={ 'auth': auth, 'otp': otp }, follow=True ) otp_confirm_response = self.client.post( '/hub/auth/forgot-credentials/step-3/{}/reveal-new-otp-secret/confirm'.format(extracted_link['uuid']), data={ 'auth': extracted_link['auth'], 'otp': get_otp(new_otp_secret) }, follow=True ) self.assertEqual(200, otp_confirm_response.status_code) self.assertEqual(0, PendingCredentialRecovery.objects.count()) self.assertEqual(new_otp_secret, HubUser.objects.get(id=user_id).get_totp_secret())
def test_in_out(self): """ Simply encrypt and decrypt """ test_items = ( b'DSJFBSHDFBSMDBFMJERFSMDNBVMSESjhdfkshdfhjkeskjedfnskdj', b'SUPER-SECRET-SUPER-SECRET-MEGA-SECRET-MORE-SECRET-EVEN-MORE', b'JustAString', ) for test_item in test_items: with self.subTest(msg='Testing with "{}"'.format(test_item)): encrypted_test_item = SymmetricCrypt().encrypt(test_item) decrypted_test_item = SymmetricCrypt().decrypt( encrypted_test_item) self.assertEqual(test_item, decrypted_test_item) self.assertNotEqual(test_item, encrypted_test_item) self.assertIsNotNone(encrypted_test_item) self.assertIsNotNone(decrypted_test_item)
def create_encrypted_random_totp_secret(secret_length: int = 72): """ Generate a random TOTP secret, encrypted :param int secret_length: How long should the unencrypted secret be? :rtype: bytes :returns: A random secret, but encrypted """ return SymmetricCrypt().encrypt(create_random_totp_secret(secret_length))
def _send_otp_form(self, request: HttpRequest, form: NewOtpSecretForm = NewOtpSecretForm(), success: bool = False): """ Send the OTP form """ secret = request.session.get('encrypted_new_totp_secret', None) if secret is not None: secret = SymmetricCrypt().decrypt( b64decode(secret.encode('us-ascii'))) return render(request, self.template_name, { 'form': form, 'new_secret': secret, 'username': request.user.username, 'success': success, }, content_type=self.content_type)
def test_encrypted_secret_generator_lengths(self): """ Test different lengths """ for test_item in self.generator_lengths: with self.subTest( msg='Testing with a length of {}'.format(test_item)): self.assertEqual( test_item, len(SymmetricCrypt().decrypt( create_encrypted_random_totp_secret(test_item))))
def get_totp_secret(self) -> Optional[bytes]: """ Get the TOTP Secret This method gives you the unencrypted (decrypted) TOTP secret :rtype: Optional[bytes] :returns: The unencrypted secret or None if no secret is set """ if self.totp_secret is None: return None return SymmetricCrypt().decrypt(self.totp_secret)
def set_totp_secret(self, secret: bytes): """ Set the TOTP Secret The TOTP Secret will be encrypted before it is saved to the database. :param bytes secret: The TOTP secret to be encrypted and saved to the database """ if len(secret) < 32: raise ValueError(_('Secret must be at least 32 bytes long')) if len(secret) > 96: raise ValueError(_('Secret must not be larger than 96 bytes')) self.totp_secret = SymmetricCrypt().encrypt(secret)
def test_encrypted_secret_generator_randomness(self): """ Test the randomness of the encrypted secret generator """ for length, amount in self.random_sets: with self.subTest( msg='Testing with a length of {} in {} iterations'.format( length, amount)): secrets = [] for _ in range(amount): secret = SymmetricCrypt().decrypt( create_encrypted_random_totp_secret(length)) self.assertNotIn(secret, secrets) secrets.append(secret)
def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user_id = kwargs.get('user_id', None) if not user_id: # pragma: no cover raise Http404() try: user_obj = HubUser.objects.get(id=user_id) # type: HubUser except HubUser.DoesNotExist: raise Http404() context['username'] = user_obj.username has_secret = user_obj.totp_secret is not None and len( user_obj.totp_secret) > 0 context['has_secret'] = has_secret if has_secret: context['otp_secret'] = b32encode(SymmetricCrypt().decrypt( user_obj.totp_secret)).decode('us-ascii') return context
def get(self, request, user_id: int, file_type: str): """ Handle GET """ (method, content_type) = self.actions[file_type] try: user_obj = HubUser.objects.get(id=user_id) # type: HubUser except HubUser.DoesNotExist: raise Http404() if not user_obj.totp_secret: raise Http404() qr_code = method( b32encode(SymmetricCrypt().decrypt(user_obj.totp_secret)), user_obj.username) response = HttpResponse(content_type=content_type) qr_code.save(response) return response
def post(self, request: HttpRequest, recovery: UUID): """ Set the new secret """ encrypted_secret = request.session.get( 'encrypted_temporary_otp_secret', None) if encrypted_secret is None: # pragma: no cover # Safeguard for tinkered session return deny_step(request, 'EA10') auth_form = ForgottenCredentialsStep3BaseForm(request.POST) if not auth_form.is_valid( ): # pragma: no cover # Safeguard Client Manipulation return deny_step(request, 'EA11') new_secret = SymmetricCrypt().decrypt(b64decode(encrypted_secret)) recovery_str = str(recovery) try: auth = Signer(salt=recovery_str).unsign( auth_form.cleaned_data['auth']) recovery_data = PendingCredentialRecovery.objects.filter( user__is_active=True, valid_until__gte=now(), recovery_type='otp-secret').get(uuid=recovery, key=auth) user = recovery_data.user except PendingCredentialRecovery.DoesNotExist: # pragma: no cover # Database Safeguard return deny_step(request, 'EA12') except (ValueError, BadSignature): # pragma: no cover # Manipulation Safeguard return deny_step(request, 'EA13') form = ForgottenCredentialsStep3ConfirmOtpForm(request.POST) if not form.is_valid(): return self.show_form(request, new_secret, user.username, recovery_str, form) if form.cleaned_data['otp'] not in get_possible_otps(new_secret): form.add_error('otp', _('This one time password is not valid.')) return self.show_form(request, new_secret, user.username, recovery_str, form) with transaction.atomic(): tx_id = transaction.savepoint() user.set_totp_secret(new_secret) user.save() recovery_data.delete() transaction.savepoint_commit(tx_id) logout(request) messages.add_message( request, messages.SUCCESS, _('Your new OTP secret has been set. You can login now.')) return redirect(reverse_lazy('ha:auth:login'))
def authenticate(self, request: HttpRequest, username=None, password=None, one_time_pw=None) -> Optional[HubUser]: # pylint: disable=arguments-differ random = SystemRandom() for i in range(random.randrange(1, 5)): # nosec HubUser().set_password('against-timing-attack' + str(i)) # Mitigation against timing attack if username is None or password is None or one_time_pw is None: return None if any([ len(username) < 1, len(username) > 150, len(password) < 1, len(password) > 1000, len(one_time_pw) != 6 ]): return None c_user = TotpAuthenticationBackend.clean_username(username) user_object = super(TotpAuthenticationBackend, self).authenticate(request, c_user, password) # type: HubUser if user_object is None: return None if user_object.totp_secret is None: return None possible_tokens = get_possible_otps(SymmetricCrypt().decrypt( user_object.totp_secret)) if one_time_pw not in possible_tokens: return None current_time = now() starting_at = current_time - datetime.timedelta(hours=1) try: BurnedOtp.objects.filter( user=user_object, burned_timestamp__gte=starting_at).get(token=one_time_pw) return None except BurnedOtp.DoesNotExist: BurnedOtp.objects.create(user=user_object, token=one_time_pw) return user_object
def post(self, request: HttpRequest, recovery: UUID): """ Set new temporary Secret and display it, ask the user to confirm with an OTP """ form = ForgottenCredentialsStep3BaseForm(request.POST) if not form.is_valid(): return deny_step(request, 'EA04') recovery_str = str(recovery) try: auth = Signer(salt=recovery_str).unsign(form.cleaned_data['auth']) user = PendingCredentialRecovery.objects.filter( user__is_active=True, valid_until__gte=now(), recovery_type='otp-secret').get(uuid=recovery, key=auth).user except PendingCredentialRecovery.DoesNotExist: # pragma: no cover # Database Safeguard return deny_step(request, 'EA05') except (ValueError, BadSignature): # pragma: no cover # Manipulation Safeguard return deny_step(request, 'EA06') new_secret = create_random_totp_secret() new_secret_encrypted = b64encode( SymmetricCrypt().encrypt(new_secret)).decode('us-ascii') request.session[ 'encrypted_temporary_otp_secret'] = new_secret_encrypted return render(request, self.template_name, { 'new_secret': new_secret, 'username': user.username, 'recovery': recovery_str, 'form': ForgottenCredentialsStep3ConfirmOtpForm( initial={'auth': form.cleaned_data['auth']}) }, content_type=self.content_type)
def test_round_trip(self): """ Test the complete Round Trip with wrong inputs and finally a good one """ self.client.login(username=self.normal_user.username, password=self.password, one_time_pw=get_otp(self.secret)) old_secret = self.normal_user.get_totp_secret() self._open() test_otps = ('', '1', '22', '333', '4444', '55555', '666666', 'aaaaaa', 'a1b2c3') for test_otp in test_otps: with self.subTest( msg='Sending OTP "{}" without creating a secret before'. format(test_otp)): response = self.client.post(self.url, data={'otp_confirm': test_otp}, follow=False) self.assertEqual(200, response.status_code) self.assertEqual( old_secret, HubUser.objects.get( id=self.normal_user.id).get_totp_secret()) for expect in (True, False): with self.subTest( msg='Creating a secret, expecting "{}"'.format(expect)): response = self.client.put(self.url) self.assertEqual(200, response.status_code) self.assertJSONEqual(response.content, {'secret_created': expect}) self.assertEqual( old_secret, HubUser.objects.get( id=self.normal_user.id).get_totp_secret()) session_data = str( b64decode( self.client.session.model.objects.first().session_data).decode( 'utf-8')) session_data = loads(session_data[session_data.index(':') + 1:]) new_otp_secret = SymmetricCrypt().decrypt( b64decode(session_data['encrypted_new_totp_secret'])) test_otps = [ '', '1', '22', '333', '4444', '55555', 'aaaaaa', 'a1b2c3' ] + [get_otp(new_otp_secret, -10)] for test_otp in test_otps: with self.subTest( msg='Sending OTP "{}" which is wrong'.format(test_otp)): response = self.client.post(self.url, data={'otp_confirm': test_otp}, follow=False) self.assertEqual(200, response.status_code) self.assertEqual( old_secret, HubUser.objects.get( id=self.normal_user.id).get_totp_secret()) with self.subTest(msg='Testing with correct OTP'): response = self.client.post( self.url, data={'otp_confirm': get_otp(new_otp_secret)}, follow=False) self.assertEqual(200, response.status_code) self.assertNotEqual( old_secret, HubUser.objects.get(id=self.normal_user.id).get_totp_secret()) self.assertEqual( new_otp_secret, HubUser.objects.get(id=self.normal_user.id).get_totp_secret())