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