1# Copyright (c) 2012-2016 Seafile Ltd. 2import logging 3from binascii import unhexlify 4from base64 import b32encode 5 6from constance import config 7from django.conf import settings 8from django.contrib.sites.shortcuts import get_current_site 9from django.urls import reverse 10from django.forms import Form 11from django.http import HttpResponse, Http404, HttpResponseRedirect 12from django.shortcuts import redirect 13from django.utils.http import is_safe_url 14from django.utils.module_loading import import_string 15from django.views.decorators.cache import never_cache 16from django.views.decorators.debug import sensitive_post_parameters 17from django.views.generic import FormView, DeleteView, TemplateView 18from django.views.generic.base import View 19 20import qrcode 21import qrcode.image.svg 22 23try: 24 from formtools.wizard.views import SessionWizardView 25except ImportError: 26 # pylint: disable=import-error,no-name-in-module 27 from django.contrib.formtools.wizard.views import SessionWizardView 28 29from seahub.auth import login as login, REDIRECT_FIELD_NAME 30from seahub.auth.decorators import login_required 31from seahub.auth.forms import AuthenticationForm 32 33from seahub.two_factor import login as two_factor_login 34from seahub.two_factor.decorators import otp_required 35from seahub.two_factor.models import (StaticDevice, PhoneDevice, 36 get_available_methods, default_device) 37from seahub.two_factor.utils import random_hex, totp_digits, get_otpauth_url 38 39from seahub.two_factor.forms import (MethodForm, TOTPDeviceForm, 40 PhoneNumberForm, DeviceValidationForm) 41from seahub.two_factor.views.utils import (class_view_decorator, 42 CheckTwoFactorEnabledMixin, 43 IdempotentSessionWizardView) 44 45logger = logging.getLogger(__name__) 46 47QR_SESSION_KEY = 'django_two_factor-qr_secret_key' 48 49@class_view_decorator(never_cache) 50@class_view_decorator(login_required) 51class SetupView(CheckTwoFactorEnabledMixin, IdempotentSessionWizardView): 52 53 redirect_url = 'two_factor:backup_tokens' 54 qrcode_url = 'two_factor:qr' 55 template_name = 'two_factor/core/setup.html' 56 initial_dict = {} 57 form_list = ( 58 # ('welcome', Form), 59 ('method', MethodForm), 60 ('generator', TOTPDeviceForm), 61 ('sms', PhoneNumberForm), 62 ('call', PhoneNumberForm), 63 ('validation', DeviceValidationForm), 64 ) 65 condition_dict = { 66 'generator': lambda self: self.get_method() == 'generator', 67 'call': lambda self: self.get_method() == 'call', 68 'sms': lambda self: self.get_method() == 'sms', 69 'validation': lambda self: self.get_method() in ('sms', 'call'), 70 } 71 72 def get_method(self): 73 method_data = self.storage.validated_step_data.get('method', {}) 74 return method_data.get('method', None) 75 76 def get(self, request, *args, **kwargs): 77 """ 78 Start the setup wizard. Redirect if already enabled. 79 """ 80 if default_device(self.request.user): 81 return redirect(self.redirect_url) 82 return super(SetupView, self).get(request, *args, **kwargs) 83 84 def get_form_list(self): 85 """ 86 Check if there is only one method, then skip the MethodForm from form_list 87 """ 88 form_list = super(SetupView, self).get_form_list() 89 available_methods = get_available_methods() 90 if len(available_methods) == 1: 91 form_list.pop('method', None) 92 93 # XXX: since we comment out first welcome step, `form_list` will 94 # be empty after pop 'method', which will cause index error in 95 # `WizardView::get` when reset to the first step, so we have to 96 # add our default method to the form list. 97 if len(form_list) == 0: 98 form_list['generator'] = TOTPDeviceForm 99 100 method_key, _ = available_methods[0] 101 self.storage.validated_step_data['method'] = {'method': method_key} 102 return form_list 103 104 def render_next_step(self, form, **kwargs): 105 """ 106 In the validation step, ask the device to generate a challenge. 107 """ 108 next_step = self.steps.__next__ 109 if next_step == 'validation': 110 try: 111 self.get_device().generate_challenge() 112 kwargs["challenge_succeeded"] = True 113 except: 114 logger.exception("Could not generate challenge") 115 kwargs["challenge_succeeded"] = False 116 return super(SetupView, self).render_next_step(form, **kwargs) 117 118 def done(self, form_list, **kwargs): 119 """ 120 Finish the wizard. Save all forms and redirect. 121 """ 122 # TOTPDeviceForm 123 if self.get_method() == 'generator': 124 form = [form for form in form_list if isinstance(form, TOTPDeviceForm)][0] 125 device = form.save() 126 127 # PhoneNumberForm / YubiKeyDeviceForm 128 elif self.get_method() in ('call', 'sms', 'yubikey'): 129 device = self.get_device() 130 device.save() 131 132 else: 133 raise NotImplementedError("Unknown method '%s'" % self.get_method()) 134 135 two_factor_login(self.request, device) 136 137 device = StaticDevice.get_or_create(self.request.user.username) 138 if device.token_set.count() == 0: 139 device.generate_tokens() 140 141 return redirect(self.redirect_url) 142 143 def get_form_kwargs(self, step=None): 144 kwargs = {} 145 if step == 'generator': 146 kwargs.update({ 147 'key': self.get_key(step), 148 'user': self.request.user, 149 }) 150 if step in ('validation', 'yubikey'): 151 kwargs.update({ 152 'device': self.get_device() 153 }) 154 metadata = self.get_form_metadata(step) 155 if metadata: 156 kwargs.update({'metadata': metadata, }) 157 return kwargs 158 159 def get_device(self, **kwargs): 160 """ 161 Uses the data from the setup step and generated key to recreate device. 162 163 Only used for call / sms -- generator uses other procedure. 164 """ 165 method = self.get_method() 166 kwargs = kwargs or {} 167 kwargs['name'] = 'default' 168 kwargs['user'] = self.request.user 169 170 if method in ('call', 'sms'): 171 kwargs['method'] = method 172 kwargs['number'] = self.storage.validated_step_data\ 173 .get(method, {}).get('number') 174 return PhoneDevice(key=self.get_key(method), **kwargs) 175 176 def get_key(self, step): 177 self.storage.extra_data.setdefault('keys', {}) 178 if step in self.storage.extra_data['keys']: 179 return self.storage.extra_data['keys'].get(step) 180 key = random_hex(20).decode('ascii') 181 self.storage.extra_data['keys'][step] = key 182 return key 183 184 def get_context_data(self, form, **kwargs): 185 context = super(SetupView, self).get_context_data(form, **kwargs) 186 if self.steps.current == 'generator': 187 key = self.get_key('generator') 188 rawkey = unhexlify(key.encode('ascii')) 189 b32key = b32encode(rawkey).decode('utf-8') 190 self.request.session[QR_SESSION_KEY] = b32key 191 context.update({'QR_URL': reverse(self.qrcode_url)}) 192 elif self.steps.current == 'validation': 193 context['device'] = self.get_device() 194 context['cancel_url'] = reverse('edit_profile') 195 return context 196 197 def process_step(self, form): 198 if hasattr(form, 'metadata'): 199 self.storage.extra_data.setdefault('forms', {}) 200 self.storage.extra_data['forms'][ 201 self.steps.current] = form.metadata 202 return super(SetupView, self).process_step(form) 203 204 def get_form_metadata(self, step): 205 self.storage.extra_data.setdefault('forms', {}) 206 return self.storage.extra_data['forms'].get(step, None) 207 208 209@class_view_decorator(never_cache) 210@class_view_decorator(otp_required) 211class BackupTokensView(CheckTwoFactorEnabledMixin, FormView): 212 """ 213 View for listing and generating backup tokens. 214 215 A user can generate a number of static backup tokens. When the user loses 216 its phone, these backup tokens can be used for verification. These backup 217 tokens should be stored in a safe location; either in a safe or underneath 218 a pillow ;-). 219 """ 220 form_class = Form 221 redirect_url = 'two_factor:backup_tokens' 222 template_name = 'two_factor/core/backup_tokens.html' 223 number_of_tokens = 10 224 225 def get_device(self): 226 return StaticDevice.get_or_create(self.request.user.username) 227 228 def get_context_data(self, **kwargs): 229 context = super(BackupTokensView, self).get_context_data(**kwargs) 230 context['device'] = self.get_device() 231 return context 232 233 def form_valid(self, form): 234 """ 235 Delete existing backup codes and generate new ones. 236 """ 237 device = self.get_device() 238 device.token_set.all().delete() 239 device.generate_tokens() 240 241 return redirect(self.redirect_url) 242 243 244@class_view_decorator(never_cache) 245@class_view_decorator(otp_required) 246class SetupCompleteView(CheckTwoFactorEnabledMixin, TemplateView): 247 """ 248 View congratulation the user when OTP setup has completed. 249 """ 250 template_name = 'two_factor/core/setup_complete.html' 251 252 def get_context_data(self): 253 return {'phone_methods': [], } 254 255 256@class_view_decorator(never_cache) 257@class_view_decorator(login_required) 258class QRGeneratorView(View): 259 """ 260 View returns an SVG image with the OTP token information 261 """ 262 http_method_names = ['get'] 263 default_qr_factory = 'qrcode.image.svg.SvgPathImage' 264 265 # The qrcode library only supports PNG and SVG for now 266 image_content_types = { 267 'PNG': 'image/png', 268 'SVG': 'image/svg+xml; charset=utf-8', 269 } 270 271 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument 272 # Get the data from the session 273 if not config.ENABLE_TWO_FACTOR_AUTH: 274 raise Http404() 275 try: 276 key = self.request.session[QR_SESSION_KEY] 277 del self.request.session[QR_SESSION_KEY] 278 except KeyError: 279 raise Http404() 280 281 # Get data for qrcode 282 image_factory_string = getattr(settings, 'TWO_FACTOR_QR_FACTORY', 283 self.default_qr_factory) 284 image_factory = import_string(image_factory_string) 285 content_type = self.image_content_types[image_factory.kind] 286 287 otpauth_url = get_otpauth_url( 288 accountname=self.request.user.username, 289 issuer=get_current_site(self.request).name, 290 secret=key, 291 digits=totp_digits()) 292 293 # Make and return QR code 294 img = qrcode.make(otpauth_url, image_factory=image_factory) 295 resp = HttpResponse(content_type=content_type) 296 img.save(resp) 297 return resp 298