Merge lp:~thomir-deactivatedaccount/canonical-identity-provider/trunk-add-gpg-key-management into lp:canonical-identity-provider/release

Proposed by Thomi Richards
Status: Rejected
Rejected by: Daniel Manrique
Proposed branch: lp:~thomir-deactivatedaccount/canonical-identity-provider/trunk-add-gpg-key-management
Merge into: lp:canonical-identity-provider/release
Diff against target: 1793 lines (+1363/-32)
30 files modified
README (+14/-7)
django_project/settings_base.py (+2/-0)
django_project/settings_devel.py (+1/-0)
requirements.txt (+1/-0)
requirements_devel.txt (+2/-0)
src/identityprovider/emailutils.py (+77/-1)
src/identityprovider/migrations/0009_authtoken_gpg_columns.py (+19/-0)
src/identityprovider/models/account.py (+4/-0)
src/identityprovider/models/authtoken.py (+23/-0)
src/identityprovider/static/css/all.css (+1/-1)
src/identityprovider/static_src/css/ubuntuone.css (+18/-0)
src/identityprovider/templates/email/gpg-cleartext-instructions.txt (+17/-0)
src/identityprovider/templates/email/validate-gpg.txt (+18/-0)
src/identityprovider/tests/factory.py (+11/-2)
src/identityprovider/tests/test_models_authtoken.py (+46/-1)
src/identityprovider/tests/utils.py (+17/-0)
src/webui/forms.py (+102/-0)
src/webui/gpg.py (+15/-0)
src/webui/templates/account/confirm_new_gpg.html (+33/-0)
src/webui/templates/account/confirm_new_signonly_gpg.html (+47/-0)
src/webui/templates/account/gpg_keys.html (+97/-0)
src/webui/templates/widgets/personal-menu.html (+4/-0)
src/webui/tests/test_views_account_gpg.py (+460/-0)
src/webui/urls.py (+7/-1)
src/webui/utils.py (+10/-0)
src/webui/views/account.py (+57/-0)
src/webui/views/gpg_messages.py (+100/-0)
src/webui/views/registration.py (+0/-1)
src/webui/views/ui.py (+115/-18)
src/webui/views/utils.py (+45/-0)
To merge this branch: bzr merge lp:~thomir-deactivatedaccount/canonical-identity-provider/trunk-add-gpg-key-management
Reviewer Review Type Date Requested Status
Natalia Bidart (community) Needs Fixing
Michael Nelson (community) Approve
Review via email: mp+291473@code.launchpad.net

Description of the change

This branch adds GPG key handling to SSO:

* Everything is behind a gargoyle feature flag, so while this isn't done, it is merge-able (although I see no reason to merge it at present).

* The email templates were copied word-for-word from launchpad, but I think there's room for improvement there.

* This MP: https://code.launchpad.net/~thomir/canonical-identity-provider/dependencies/+merge/292105 contains an updated dependencies branch. You'll either need to land that first, or tweak Makefile in this branch to point to that dependency branch instead of the default.

THINGS DEFERRED FOR A LATER BRANCH:
======================================

* Test optimisations: don't restart gpgservice and keyserver every test. Ideally we'd be able to use testresources, but I'll need to find a way to make it play nicely with django tests.

* Fix help links in templates to be accordion sections with inline helps.

* Implement deactivation and reactivation workflows.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

I've made a number of changes. A re-review would be much appreciated.

Revision history for this message
Michael Nelson (michael.nelson) wrote :

Bunch of things which we chatted about below, +1 with those changes, but we'll probably need to go over the form things.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

I've made the changes we discussed, with the exception of talking to the front-end design folk about the style review. I think we can leave this until we have the whole workflow implemented?

Cheers,

Revision history for this message
Michael Nelson (michael.nelson) wrote :

Thanks Thomi. +1 with a few comments below. Land when ready :)

review: Approve
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Hello Michael, Thommy,

I was a little bit surprised with the adding of testtools' TestCase and testtools.matchers to these tests. I understand the difference in using matchers vs standard TestCase assertion methods, and leaving aside the discussion whether is something we want or not, I would like us to talk about adding them to SSO or any other existing big project.

SSO is a large project with a lot of code, and in many past conversations the OLS team have agreed that given the amount of people working in every project, and given the importance of being able to switch between projects easily and fast, one of the things we value the most is consistency (on top of things that may be a nice to have in other contexts).

Starting using testtools for our unit tests is something that breaks consistency, and wasn't agreed at team level in advanced. The testtools dependency was there only for the acceptance tests.

Even more, using multiple inheritance on different base TestCases is something that has been proven to be a complete disaster in the mid term, we suffered from this in the Ubuntu One code where we had the Twisted TestCase, the Django TestCase, and the testtools TestCase as multiple base test cases. All the benefits a particular TestCase can give, are lost and become a burden when combining more than one as base classes.

For our Django projects, the OLS decision was to use the Django TestCase with a few extra, custom helpers defined. After a quick look, most matchers usage can be replaced with perfectly valid assertion from the Django TestCase:

self.assertThat(keys, HasLength(1))
self.assertThat(resp.content.decode('utf8'), Not(Contains('GPG Keys')))
self.assertThat(resp.status_code, Equals(200))

self.assertEqual(len(keys), 1)
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, 'GPG Keys')

(note that the usage of assertContains/assertNotContains will also be clever about decoding the reponse, grabbing the text content, etc, something that otherwise is being made by hand and is a more error prone).

For other more complex matchers, like these:

self.assertThat(email, EmailContainsPGPBlock())
self.assertThat(keys[0]['fingerprint'], FingerprintEquals(encryption_fingerprint))

the existing methodology is to create custom assert helpers that isolate the specific logic needed for each case:

def assert_gpg_block_in_email(self, email)
def assert_fingerprint_encryption_in keys(self, keys)

In summary, I would kindly ask you to maintain consistency in line within the existing project, or at least bring this issue to the whole OLS team and reach consensus and have a migration plan before landing this to trunk.

Thank you.

review: Needs Fixing
Revision history for this message
Michael Nelson (michael.nelson) wrote :
Download full text (6.6 KiB)

On Wed, Apr 20, 2016 at 11:39 PM Natalia Bidart <
<email address hidden>> wrote:

> Review: Needs Fixing
>
> Hello Michael, Thommy,
>

Hi Natalia,

>
> I was a little bit surprised with the adding of testtools' TestCase and
> testtools.matchers to these tests. I understand the difference in using
> matchers vs standard TestCase assertion methods, and leaving aside the
> discussion whether is something we want or not, I would like us to talk
> about adding them to SSO or any other existing big project.
>

I had assumed that using testtools here would be a non-issue given that it
was already a dependency of the project - that's my fault if anyone's. And
yes, we're happy to bring that up on the list, and not use testtools here
if that's better for the team - but note, this wasn't about using
testtools.matchers but rather avoiding adding more tech-debt in SSO when
testing the SSO<->GPGService interactions.

>
> SSO is a large project with a lot of code, and in many past conversations
> the OLS team have agreed that given the amount of people working in every
> project, and given the importance of being able to switch between projects
> easily and fast, one of the things we value the most is consistency (on top
> of things that may be a nice to have in other contexts).
>

+100 for consistency - I'd not seen this as breaking consistency at all.
Perhaps a silly example, but if you grep the current SSO codebase for
"assertEqual.*True" and "assertTrue" you'll see that there are many tests
using:

assertEqual(result, True)

and many others using:

assertTrue(result)

 - which is fine, I've never felt the need to make those consistent because
they read well and have clear intent, as does assertThat(result, Is(True))
. There are obviously lots of other benefits to testtools matchers, but I
don't want to derail the main point here, rather just highlight why I
didn't see this as a consistency issue at all.

It was not because of matchers that we started using the existing testtools
dependency here...

>
> Starting using testtools for our unit tests is something that breaks
> consistency, and wasn't agreed at team level in advanced.

As above, I'd not even thought of the use of matchers as a consistency
issue but am happy to accept that it is for some people, so that's
completely my fault for not recognising it as such and bringing that up
with the wider team. We'll do that, and remove the use of matchers if
that's the consensus - that's not an issue nor the reason we used testtools
here...

> The testtools dependency was there only for the acceptance tests.
>
> Even more, using multiple inheritance on different base TestCases is
> something that has been proven to be a complete disaster in the mid term,
> we suffered from this in the Ubuntu One code where we had the Twisted
> TestCase, the Django TestCase, and the testtools TestCase as multiple base
> test cases. All the benefits a particular TestCase can give, are lost and
> become a burden when combining more than one as base classes.
>

The only issue with test inheritance that I'm aware of and totally agree
with the issue, is when individual tests are re-used via inheritance of
test-cases ...

Read more...

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Hi Natalia,

I've removed the use of testtools matchers and assertThat statements. I no longer derive from testtools.TestCase, and I've re-implemented a less powerful version of testtools' useFixture (see use_fixture function) so we can get the benefits of testing against a real gpgservice without worsening the learning curve for new engineers.

Could you please re-review, if you have the time?

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Thanks for the re-work. Added some comments, hopefully you could map some of the comments to match all recurrences of the same type (such as the docstring formatting, the self.client usage, the line wrapping for lists, dicts and import parenthesis, etc).

I was also wondering if there may be any chance you could extract the 2fa login decorator work into a pre-requisite MP to ease the review and landing process. This way you could also add the missing test case for this new decorator.

Lastly, note that src/webui already has decorators module, so I would suggest moving the new one there.

1426. By Thomi Richards

Fixes from code review.

1427. By Thomi Richards

Clean up generation of unknown_key message text generation.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Hi Natalia,

I've checked off most of the items from your review, and commented where I think there's room for discussion.

I haven't gotten to separate out the new twofactor decorator into a separate branch. I probably won't get a chance to finish that before the end of my day. Perhaps you'd be willing to re-review this branch on the understanding that I'm working on that separate branch?

Thanks,

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Re-reviewed. One nitpick I missed before is that we try to name all the testcases with the pattern

FooBarTestCase

(ie always use the TestCase prefix instead of Tests).

Thanks!

1428. By Thomi Richards

Several fixes from code review.

1429. By Thomi Richards

Yet more changes from the code review.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

I've made the changes you asked for, with one main exception. I've added diff comments where appropriate to explain my rationale.

Cheers,

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Hi Thomi, thanks for your changes. I will be reviewing them shortly.

Just wanted to share that my review process is iterative, particularly with branches this big, is very hard for me to pay the same level attention to all lines when there is so much to read.

If it helps, let me share how my review process works: first, I made a first quick pass correcting everything that is code style and try to pin point any important issues that may need early discussion (like the testtools multiple inheritance and matchers usage).

Once that's resolved, I do another pass more focused on the logic, where the code style issues are no longer a distraction and I can devote my whole attention to the content and semantics of the code.

Once that's completed, I focus on the tests, to ensure nothing has been left out once the logic is settled.

(a natural consequence is that when SSO-and-friend-projects code style and tools are unified, reviews happen faster, because usually there is no roundtrip required between phases 1 and 2)

I understand this can be a painful process (particularly in our case) because the timezone difference, so I will try to do my best on merging all the passes into the less amount possible (but this requires extra time and effort). For future reference, the process can also be speed up if the changes would be split in smaller, self-contained branches.

Thank you for your patience and understanding.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Added minimal replies to try not to add confusion to the inline threads. Will branch now and test IRL and try to focus on logic and tests. Will add another general comment with my findings.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Another trivial thing to fix while I review is the lint errors resulting from running "make lint".

1430. By Thomi Richards

Fix lint errors.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :
Download full text (3.7 KiB)

Just made it to the forms.py changes, but will try to complete this later today.

== Needs information ==

* Do we really need fingerprint = models.TextField(null=True, blank=True) to
allow null=True? In Django, is generally discouraged to use both null=True and
blank=True for char/text field, for documentation please see:

https://docs.djangoproject.com/en/1.9/ref/models/fields/#null

If this is because the need of not setting the default '' in every existing row,
is fine, but we really need to be aware that the code will need to handle
fingerprint being None or '' for the "not set" case.

== Needs fixing ==

* The strings in the send_gpg_validation_message should be marked for
translation (salutation, closing).

* validation_phrase has a non compliant with pep-257 docstring, and the phrase
returned in validation_phrase is not marked for translation.

* validation_phrase does not have tests that I could find, would you please add
some if that is the case?

* src/identityprovider/templates/email/gpg-cleartext-instructions.txt should
mark the blocks for translation. This should be checked for all user-facing
strings added in this branch (see also strings in src/webui/forms.py).

* help_text=mark_safe in ClaimOpenPGPKeyForm should use the to-be-added lazy
version in combination with string translation for the user facing message.

== The following may needs fixing but needs confirmation from Ricardo ==

* The current sso code base is deployed so it will serve under 2 prod domains:

login.ubuntu.com
login.launchpad.net

Is my understanding that the setting SSO_ROOT_URL will not return the correct
base url in all cases, so last time I checked, we really need to build absolute
urls using request.build_absolute_uri(path).

If my suspicions are correct, we need to move this method out from the Account
models to a view layer, so we can pass and use the request (we should never pass
a request to the models layer). So in this to-confirm scenario the helper could
live in src/webui/accounts.py module and look something like this:

    def get_openid_identity_url_for_user(request):
        """Get the full openid identity url for the logged in user."""
        return request.build_absolute_uri(request.user.get_absolute_url())

== Desired but optional ==

* The method validation_phrase added inside the AuthToken model feels like it
does not belong there because is just building a specific user-facing string,
and IMHO it has nothing to do with the model's business logic. In my opinion,
validation_phrase is really a helper method from VerifySignedTextForm, so the
context can be constructed with:

'validation_phrase': form.validation_phrase(atrequest)

* We usually name settings that point to other services with the pattern:
SERVICE_NAME_URL instead of SERVICE_NAME_ENDPOINT. So perhaps is worth
considering GPG_SERVICE_URL.

* I'm a little nervous about the asserts this branch adds to production code.
I really like we are being strict on preconditions, but my worry is that if for
some unexpected reason we fail the precondition, we are exploding in the user
face instead of doing something nicer. Anyways, given that we can catch this
soon if we keep a close ey...

Read more...

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :
Download full text (4.3 KiB)

> * Do we really need fingerprint = models.TextField(null=True, blank=True) to
> allow null=True? In Django, is generally discouraged to use both null=True and
> blank=True for char/text field, for documentation please see:
>
> https://docs.djangoproject.com/en/1.9/ref/models/fields/#null
>
> If this is because the need of not setting the default '' in every existing
> row,
> is fine, but we really need to be aware that the code will need to handle
> fingerprint being None or '' for the "not set" case.

I just copied all the other text-based fields in that model. I've changed it now to just have 'blank=True'.

> * The strings in the send_gpg_validation_message should be marked for
> translation (salutation, closing).

Fixed - thanks.

> * validation_phrase has a non compliant with pep-257 docstring, and the phrase
> returned in validation_phrase is not marked for translation.

I've fixed the docstring. The validation phrase wasn't marked for translation because I was worried that translating the string might break the validation workflow. I *think* it should be OK, but we need to be careful about security here: I'm not sure what controls we have over who can translate strings, who reviews translated strings etc. I need to think about this point some more. I'll talk to wgrant who may have some suggestions here.

> * validation_phrase does not have tests that I could find, would you please
> add
> some if that is the case?

Done.

> * src/identityprovider/templates/email/gpg-cleartext-instructions.txt should
> mark the blocks for translation. This should be checked for all user-facing
> strings added in this branch (see also strings in src/webui/forms.py).

Done, I think.

> * help_text=mark_safe in ClaimOpenPGPKeyForm should use the to-be-added lazy
> version in combination with string translation for the user facing message.

Done.

>
> == The following may needs fixing but needs confirmation from Ricardo ==
>
> * The current sso code base is deployed so it will serve under 2 prod domains:
>
> login.ubuntu.com
> login.launchpad.net
>
> Is my understanding that the setting SSO_ROOT_URL will not return the correct
> base url in all cases, so last time I checked, we really need to build
> absolute
> urls using request.build_absolute_uri(path).
>

I'm happy to make this change, and the one below (move validation phrase into the form) if this turns out to be correct. The important thing here is that this *always* returns the same URL. If someone comes via login.launchpad.net, this should *always* return 'login.ubuntu.com', regardless of the URL the user is visiting.

Given the above, please let me know which is more correct: settings.SSO_ROOT_URL or request.build_absolute_uri(...).

>
> == Desired but optional ==
>
> * The method validation_phrase added inside the AuthToken model feels like it
> does not belong there because is just building a specific user-facing string,
> and IMHO it has nothing to do with the model's business logic. In my opinion,
> validation_phrase is really a helper method from VerifySignedTextForm, so the
> context can be constructed with:
>
> 'validation_phrase': form.validation_phrase(atrequest)

As above, hap...

Read more...

1431. By Thomi Richards

Several fixes from code review.

1432. By Thomi Richards

Fix translation error.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Adding a second batch of comments:

== Needs fixing ==

* The usual pattern in Django for managing forms is having the action being
represented by the form submission button from the template, and not by adding a
hidden field to the form class. If multiple actions are needed, many submit
buttons with different names are usually defined in the template. If you haven't
yet, I would advice reading:

https://docs.djangoproject.com/en/1.9/topics/forms/

So for the particular case of ClaimOpenPGPKeyForm, the class could be something
among these lines:

FINGERPRINT_CODE_EXAMPLE = (
    '<code>27E0 7815 B47C 0397 90D5&nbsp;&nbsp;8589 27D9 A27B F3F9 6058</code>'
)

class ClaimOpenPGPKeyForm(forms.Form):

    """A form to validate the claiming of an OpenPGP key."""

    fingerprint = forms.CharField(
        help_text=mark_lazy_safe(
            _("For example: ") + FINGERPRINT_CODE_EXAMPLE),
        error_messages={'required': gpg_messages.bad_fingerprint_format()},
    )

    def clean_fingerprint(self):
        # ...
        return sane_fingerprint

And the template should look something like this:

  <form name="gpg_actions" action="" method="POST">
    {% csrf_token %}
    {{ claim_form }}
    <input class="cta" type="submit" name="claim_gpg" value="{% 'Import Key' %}"/>
  </form>

And in the view just remove the code to check the action in the POST dict, given
the form submission itself represents the single available action. If you feel
strong about checking the action name, you still could do (but this is uncommon
in Django views when a single action can be POSTed):

    if request.method == 'POST' and 'claim_gpg' in request.POST:
        form = ...

* I've found more user facing strings missing translations, I trust you will
review all the new code to fix this? (label='Clearsigned Text', <p> block in
gpg_keys.html, etc).

* There are no tests that I could find for the newly added src/webui/forms.py
and its forms. We should add a src/webui/tests/test_forms.py with proper tests
for the fields and custom clean logic.
It may be worth splitting the adding of the form file and its tests into a new branch, it will
certainly help the review process (and this would also include the new GPG service dependencies, and perhaps the new fixture usage).

== Nice to have ==

* Generally, in Django views, after successful POST processing, when redirecting
the user to the succes URL the UI should also show a success message so the user
gets confirmation that all went well. See for example the account_edit view.

* I know that all views in account.py use render_to_response, so I can certainly
understand why you choose to use the same helper to maintain compatibility. But
we have tried to migrate as we go to the new render() helper which is the
recommended function to use, given render_to_response will be deprecated soon:

https://docs.djangoproject.com/en/1.9/topics/http/shortcuts/#render-to-response

So I think it can be a good idea to use render in the gpg new view instead of
render_to_response. In the same nitpick category, is a bit more friendly to
django developers that the dict context variable is called 'context' instead of
template_vars.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Just a note about translating the validation phrase: After talking to William about this, there are valid security concerns about not making this translatable.

An attacker could, for example, submit a translation that happened to match some signed text they'd received (in an email, perhaps) from a key they want to claim as their own.

I'd hope that we have something like reviews of translation messages, but I can easily imagine how such a change might get lost in the noise.

We might want to translate this in the future, but we should think very carefully about how we do this in a safe, secure manner.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Hi Natalia,

You're absolutely correct - this would be a lot easier for both of us if the diff was smaller. I think we should put this MP on hold for now (I won't delete it, so your current work isn't wasted, I'll look at your comments). Instead, I'll start preparing a series of much smaller branches for your review.

The first such branch is here:

https://code.launchpad.net/~thomir/canonical-identity-provider/trunk-add-mark-safe-lazy/+merge/293195

Sorry about that. I think this is the best way forwards though. I look forward to your review on these new, smaller, branches.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

> Hi Natalia,
>
> You're absolutely correct - this would be a lot easier for both of us if the
> diff was smaller. I think we should put this MP on hold for now (I won't
> delete it, so your current work isn't wasted, I'll look at your comments).
> Instead, I'll start preparing a series of much smaller branches for your
> review.

I very much agree and appreciate this extra effort. Thank you.

Unmerged revisions

1432. By Thomi Richards

Fix translation error.

1431. By Thomi Richards

Several fixes from code review.

1430. By Thomi Richards

Fix lint errors.

1429. By Thomi Richards

Yet more changes from the code review.

1428. By Thomi Richards

Several fixes from code review.

1427. By Thomi Richards

Clean up generation of unknown_key message text generation.

1426. By Thomi Richards

Fixes from code review.

1425. By Thomi Richards

Don't derive from testtools.TestCase either.

1424. By Thomi Richards

Remove use of testtools matchers from gpg unit tests.

1423. By Thomi Richards

Merged trunk.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'README'
2--- README 2016-03-11 15:00:46 +0000
3+++ README 2016-04-28 00:53:41 +0000
4@@ -7,7 +7,7 @@
5
6 0. The supported way to develop Canonical Identity Provider is using LXC's.
7 There's some additional documentation on LXC gotchas in the following
8-page (check there specially if you can't resolve the lxc address when
9+page (check there specially if you can't resolve the lxc address when
10 ssh'ing and if you want to open GUI apps seeing them in your host machine):
11
12 https://wiki.canonical.com/UbuntuOne/Developer/LXC
13@@ -209,23 +209,30 @@
14 Note: this is two separate steps as in Mojo specs we need to separate the
15 dependency collect step from the build step, as the build step runs in an
16 lxc with no internet access at all.
17-
18-
19-13. (Optional) Set up private/public keys to make macaroons work between
20- projects
21+
22+
23+13. (Optional) Set up private/public keys to make macaroons work between
24+ projects
25
26 For some endpoints to work correctly, the system needs to decrypt keys
27- that were encrypted from other services (you'll need to use this
28+ that were encrypted from other services (you'll need to use this
29 instructions, or the corresponding one in the other projects).
30
31 So, create a pair of keys for this project:
32
33 ssh-keygen -t rsa -N "" -f project_id_rsa
34
35- This will leave you with two files, move the private one into the
36+ This will leave you with two files, move the private one into the
37 ``rsakeys`` directory, and the .pub one into this same dir in the
38 other projects.
39
40+14. (Optional) Configure the GPG service endpoint.
41+
42+ This is required if you want to use any of the gpg-related features in SSO.
43+ You'll need to run a gpgservice instance somewhere (instructions are in
44+ the project's root), and set the 'GPGSERVICE_ENDPOINT' setting in your
45+ settings.py to point to the IP_address:tcp_port pair where the service is
46+ running.
47
48 BAZAAR
49 ------
50
51=== modified file 'django_project/settings_base.py'
52--- django_project/settings_base.py 2016-04-25 18:31:45 +0000
53+++ django_project/settings_base.py 2016-04-28 00:53:41 +0000
54@@ -184,6 +184,8 @@
55 GARGOYLE_SWITCH_DEFAULTS = {}
56 GEOIP_PATH = '/usr/share/GeoIP'
57 GOOGLE_ANALYTICS_ID = 'THE-GOOGLE-ID'
58+GPGSERVICE_ENDPOINT = None
59+GPGSERVICE_TIMEOUT = 5
60 HANDLER_TIMEOUT_MILLIS = 2500
61 HONEYPOT_FIELD_NAME = 'openid.usernamesecret'
62 HOTP_BACKWARDS_DRIFT = 0
63
64=== modified file 'django_project/settings_devel.py'
65--- django_project/settings_devel.py 2016-04-04 15:38:38 +0000
66+++ django_project/settings_devel.py 2016-04-28 00:53:41 +0000
67@@ -26,6 +26,7 @@
68 'CAPTCHA_NEW_ACCOUNT': {'is_active': False},
69 'PREFLIGHT': {'is_active': True},
70 'LOGIN_BY_TOKEN': {'is_active': True},
71+ 'GPG_SERVICE': {'is_active': True},
72 }
73 PASSWORD_HASHERS = [
74 'django.contrib.auth.hashers.SHA1PasswordHasher',
75
76=== modified file 'requirements.txt'
77--- requirements.txt 2016-03-14 17:21:32 +0000
78+++ requirements.txt 2016-04-28 00:53:41 +0000
79@@ -33,3 +33,4 @@
80 timeline==0.0.4
81 timeline-django==0.0.4
82 whitenoise==2.0.6
83+gpgservice-client==0.0.3
84
85=== modified file 'requirements_devel.txt'
86--- requirements_devel.txt 2016-04-11 16:49:59 +0000
87+++ requirements_devel.txt 2016-04-28 00:53:41 +0000
88@@ -6,6 +6,7 @@
89 extras==0.0.3
90 fixtures==0.3.12
91 flake8==2.4.0
92+gpgservice==0.1.3
93 junitxml==0.7
94 localmail==0.3
95 logilab-astng==0.24.3
96@@ -24,6 +25,7 @@
97 sst==0.2.5dev
98 testscenarios==0.4
99 testtools==0.9.39
100+txfixtures==0.1.4
101 u1-test-utils==0.5
102 ucitests==0.4.1
103 wsgi-intercept==0.5.1
104
105=== modified file 'src/identityprovider/emailutils.py'
106--- src/identityprovider/emailutils.py 2016-01-13 02:42:16 +0000
107+++ src/identityprovider/emailutils.py 2016-04-28 00:53:41 +0000
108@@ -10,7 +10,10 @@
109 from django.template import RequestContext
110 from django.template.loader import render_to_string
111 from django.utils.timezone import now
112-from django.utils.translation import ugettext_lazy as _
113+from django.utils.translation import (
114+ ugettext,
115+ ugettext_lazy as _,
116+)
117
118 from identityprovider.models import AuthToken, EmailAddress
119 from identityprovider.models.const import AuthTokenType, EmailStatus
120@@ -381,3 +384,76 @@
121 to = [format_address(e)
122 for e in settings.TWOFACTOR_FAILURE_NOTIFICATION_EMAILS]
123 return send_mail(subject, msg, from_email, to)
124+
125+
126+def send_gpg_validation_message(root_url, key_details, token, requester,
127+ gpg_client):
128+ """Craft the confirmation message that will be sent to the user.
129+
130+ There are two chunks of text that will be concatenated together into a
131+ single text/plain part. The first chunk will be the clear text
132+ instructions providing some extra help for those people who cannot
133+ read the encrypted chunk that follows. The encrypted chunk will
134+ have the actual confirmation token in it, however the ability to
135+ read this is highly dependent on the mail reader being used, and how
136+ that MUA is configured.
137+
138+ :param root_url: The root URL this instance of SSO is hosted at
139+ :param key_details: A dictionary of PGP key details, as returned by
140+ SSOGPGClient.getKeyDetailsFromKeyServer(...)
141+ :param token: The AuthToken instance that was generated when the GPG key
142+ was claimed.
143+ :param requester: The Account instance that initiated the gpg claim.
144+ :param gpg_client: An instance of SSOGPGClient.
145+
146+ :raises AssertionError: If the token's type is not either VALIDATEGPG or
147+ VALIDATESIGNONLYGPG.
148+ """
149+ assert token.token_type in (
150+ AuthTokenType.VALIDATEGPG, AuthTokenType.VALIDATESIGNONLYGPG), (
151+ "Cannot send gpg validation email for token type %d. Expected"
152+ "VALIDATEGPG or VALIDATESIGNONLYGPG" % token.token_type)
153+ separator = '\n '
154+ formatted_uids = ' ' + separator.join(key_details['emails'])
155+
156+ # Here are the instructions that need to be encrypted.
157+ template = 'email/validate-gpg.txt'
158+ context = {
159+ 'requester': requester.displayname,
160+ 'requesteremail': requester.preferredemail,
161+ 'displayname': key_details['displayname'],
162+ 'fingerprint': key_details['fingerprint'],
163+ 'uids': formatted_uids,
164+ 'token_url': urljoin(root_url, token.get_absolute_url()),
165+ }
166+
167+ context = RequestContext(None, context)
168+ token_text = render_to_string(template, context_instance=context)
169+
170+ # These strings aren't lazily translated since we need to render them
171+ # just a few lines further down this function.
172+ salutation = ugettext('Hello,\n\n')
173+ instructions = ''
174+ closing = ugettext('Thanks,\n\nThe Ubuntu One Team')
175+
176+ # Encrypt this part's content if requested.
177+ if key_details['can_encrypt']:
178+ token_text = gpg_client.encryptContent(
179+ key_details['fingerprint'], token_text.encode('utf-8'))
180+ # In this case, we need to include some clear text instructions
181+ # for people who do not have an MUA that can decrypt the ASCII
182+ # armored text.
183+ instructions = render_to_string(
184+ 'email/gpg-cleartext-instructions.txt', context)
185+
186+ # Concatenate the message parts and send it.
187+ text = salutation + instructions + token_text + closing
188+ from_name = 'Ubuntu One OpenPGP Key Confirmation'
189+ from_address = format_address(settings.NOREPLY_FROM_ADDRESS, from_name)
190+ subject = 'Ubuntu One: Confirm your OpenPGP Key'
191+ send_mail(
192+ subject,
193+ text,
194+ from_address,
195+ [format_address(requester.preferredemail.email)]
196+ )
197
198=== added file 'src/identityprovider/migrations/0009_authtoken_gpg_columns.py'
199--- src/identityprovider/migrations/0009_authtoken_gpg_columns.py 1970-01-01 00:00:00 +0000
200+++ src/identityprovider/migrations/0009_authtoken_gpg_columns.py 2016-04-28 00:53:41 +0000
201@@ -0,0 +1,19 @@
202+# -*- coding: utf-8 -*-
203+from __future__ import unicode_literals
204+
205+from django.db import migrations, models
206+
207+
208+class Migration(migrations.Migration):
209+
210+ dependencies = [
211+ ('identityprovider', '0008_accountpassword_date_changed'),
212+ ]
213+
214+ operations = [
215+ migrations.AddField(
216+ model_name='authtoken',
217+ name='fingerprint',
218+ field=models.TextField(null=True, blank=True),
219+ ),
220+ ]
221
222=== modified file 'src/identityprovider/models/account.py'
223--- src/identityprovider/models/account.py 2016-03-23 15:57:01 +0000
224+++ src/identityprovider/models/account.py 2016-04-28 00:53:41 +0000
225@@ -213,6 +213,10 @@
226 def get_absolute_url(self):
227 return reverse('server-identity', args=[self.openid_identifier])
228
229+ def get_openid_identity_url(self):
230+ """Get the full openid identity url."""
231+ return settings.SSO_ROOT_URL + self.get_absolute_url()
232+
233 @property
234 def need_backup_device_warning(self):
235 return self.warn_about_backup_device and self.devices.count() == 1
236
237=== modified file 'src/identityprovider/models/authtoken.py'
238--- src/identityprovider/models/authtoken.py 2016-03-14 20:44:58 +0000
239+++ src/identityprovider/models/authtoken.py 2016-04-28 00:53:41 +0000
240@@ -58,6 +58,8 @@
241 AuthTokenType.INVALIDATEEMAIL,
242 AuthTokenType.VALIDATEEMAIL,
243 AuthTokenType.PASSWORDRECOVERY,
244+ AuthTokenType.VALIDATEGPG,
245+ AuthTokenType.VALIDATESIGNONLYGPG,
246 ]
247
248 def create(self, **kwargs):
249@@ -108,6 +110,8 @@
250 displayname = DisplaynameField(null=True, blank=True)
251 password = PasswordField(null=True, blank=True)
252
253+ fingerprint = models.TextField(blank=True)
254+
255 objects = AuthTokenManager()
256
257 class Meta:
258@@ -204,6 +208,25 @@
259 token.date_consumed = right_now
260 token.save()
261
262+ @property
263+ def validation_phrase(self):
264+ """Get a validation phrase for this authtoken.
265+
266+ AuthToken objects for sign-only keys require the user to sign a text
267+ phrase with the key they claim they own.
268+
269+ :raises AssertionError: if the token_type is not VALIDATESIGNONLYGPG.
270+ """
271+ assert self.token_type == AuthTokenType.VALIDATESIGNONLYGPG, \
272+ "Invalid token type %d, expected VALIDATESIGNONLYGPG" \
273+ % self.token_type
274+ return ('Please register %s to the\n'
275+ 'Ubuntu One user with the email address %s.\n'
276+ '%s') % (
277+ self.fingerprint,
278+ self.requester.preferredemail,
279+ self.date_created.strftime('%Y-%m-%d %H:%M:%S'))
280+
281
282 def create_unique_token_for_table(token_length=AUTHTOKEN_LENGTH,
283 obj=AuthToken, column='hashed_token'):
284
285=== modified file 'src/identityprovider/static/css/all.css'
286--- src/identityprovider/static/css/all.css 2015-12-15 13:05:44 +0000
287+++ src/identityprovider/static/css/all.css 2016-04-28 00:53:41 +0000
288@@ -1,1 +1,1 @@
289-html{color:#000;background:#FFF}body,div,dl,dt{margin:0;padding:0}dd{margin:0}ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea{margin:0;padding:0}p{padding:0}blockquote,th,td{margin:0;padding:0}table{border-spacing:0}fieldset,img{border:0}address,caption,cite,code,dfn{font-style:normal;font-weight:400}em{font-weight:400}strong,th,var{font-style:normal}th,var{font-weight:400}li{list-style:none}caption,th{text-align:left}q:before,q:after{content:''}abbr,acronym{border:0;font-variant:normal}sup{vertical-align:text-top}sub{vertical-align:text-bottom}input,textarea,select{font-size:inherit;font-weight:inherit;*font-size:100%}legend{color:#000}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}html,body{background:#fff}iframe{border:0;background:#EFEDEC}.breadcrumb li{float:left;margin-right:.5em;font-size:16px}.breadcrumb li:after{content:" >"}.breadcrumb li.last:after{content:""}.show-nojs{display:block}.show-ib-nojs{display:inline-block}.show-i-nojs{display:inline}.js .show-nojs,.js .show-ib-nojs,.js .show-i-nojs,.hide-nojs,.hide-ib-nojs,.hide-i-nojs{display:none}.js .hide-nojs{display:block}.js .hide-ib-nojs{display:inline-block}.js .hide-i-nojs,.ie7 .js .hide-ib-nojs{display:inline}.hidden{display:none}.external:hover:after{content:" " url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAAZiS0dEAN0ASAAU7HUIkgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oLBAocL8fy1n0AAADESURBVBjThVAxD8FgFLyv9QMaJIJYEDMxk3TrbDayNNGfoNGfYDAZbUaLTcLMYpOySCWSp+lqqGewfF9L3PYul3t3J5AAOzrjCzLyEXQrfDbaMJy5Isp5BWgyUd5eRS1a4dlrpR19C6lXxdEQYYLTAMBYEIwFodbQwR0XoWkju5kpzpqcQxaJ3QSl5g3RdKCW4Y6Ly/GAKj6i8ylGfQ0RdPcstrGAb4GJiImIX0uP2dGZiDiZXWkdmjYe4zt+7hj18/iHN91rTR+X+JGpAAAAAElFTkSuQmCC")}.subtitle{font-size:13px}::-webkit-input-placeholder{color:#989898}:-moz-placeholder,::-moz-placeholder{color:#989898}:-ms-input-placeholder{color:#989898}.u1-list{margin:1em 0;padding:0}.u1-list li,.u1-list dt{margin:0 0 .3em;padding:0;list-style:inside;list-style-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAHAgMAAABW/tR+AAAAAXNSR0IArs4c6QAAAAlQTFRFhwAFmZmZzMzMmHB+2AAAAAF0Uk5TAEDm2GYAAAABYktHRACIBR1IAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH2wIWFgMB/x6h+AAAABBJREFUCNdjYAADzgQQggEACZYA000oxv4AAAAASUVORK5CYII=)}dt{font-weight:700;display:list-item}dd{padding:0 0 1em}table{width:100%;border:0}table,.ie7 table{border-collapse:collapse}table.gen-listing{table-layout:fixed}.gen-listing th{background-color:#fff;border-top:none;border-bottom-style:solid}.gen-listing td,.gen-listing th{border-width:1px 0}.gen-listing tr:last-child td,.gen-listing tr:last-child th{border-bottom-style:solid}html,body{background:#E6E3E1;height:100%}#cont{max-width:59em;margin:0 auto;background:#fff;position:relative;min-height:100%}header,#content,footer{overflow:hidden}header{position:relative;border-bottom:3px solid #DD4814;padding:20px 15px 15px}@media all and (min-width:480px){header{padding:20px 20px 15px}#cont:after{padding-bottom:150px;display:block;content:" ";clear:both}footer{position:absolute;bottom:0;height:74px;left:0;right:0}}.sidebar #content,.sidebar #content header{padding:0}#content header{padding:5px 0 20px;border:none}#content header.leader{margin:20px -10px}.services #content header.leader,.home #content header.leader{margin-top:0}.services #content header.leader{margin-bottom:40px}.page-title{padding:20px 0 25px}.page-title :last-child{margin-bottom:0}.cta,.cta:link,.cta:visited{color:#fff;padding:.1em .75em;background:#dd4814;background:linear-gradient(#f39455 0%,#ef5e1f 5%,#dd4814 100%);border:1px solid #ad2e03;display:inline-block;text-decoration:none;font-size:108%;line-height:1.5em;border-radius:3px}button.cta{cursor:pointer}.cta:focus,.cta.secondary:focus{border-color:#333;-o-box-shadow:#f7f6f5 0 0 0 1px;box-shadow:#f7f6f5 0 0 0 1px}.cta:hover{background:#f28a45;background:linear-gradient(#f39455 0%,#f28a45 5%,#dd4814 100%)}.cta:focus,.cta:active{color:#fff;background:#dd4814;background:linear-gradient(#dd4814 0%,#bf3b0d 90%,#f39455 100%);border-color:#333}.cta:disabled,.cta.disabled{color:#f9dbd0;color:rgba(255,255,255,.6);border-color:#deab9a;background:#f8bd9d;background:linear-gradient(#f8bd9d 0%,#f6ad8e 5%,#eea489 100%)}.cta.secondary{color:#333;border-color:#aea79f;background:#e6e6e6;background:linear-gradient(#fff 0%,#f7f7f7 5%,#e6e6e6 100%)}.cta.secondary:hover{background:#f7f7f7;background:linear-gradient(#fff 0%,#fff 5%,#e6e6e6 100%)}.cta.secondary:focus,.cta.secondary:active{color:#333;background:#e6e6e6;background:linear-gradient(#e6e6e6 0%,#cdcdcd 90%,#fff 100%)}.cta.secondary.disabled:active,.cta.secondary:disabled{padding:0 10px;color:#b8b8b8;color:rgba(51,51,51,.3);border-color:#cac6c1;background:#fff;background:linear-gradient(#fff 0%,#f9f9f9 5%,#efefef 100%)}section table{border-top:1px solid #ccc;border-bottom:1px solid #ccc}section table tr{border-top:1px dotted #D1D1D1}section table td{padding:.5em 0}section table td:first-child{border-top:none}.yui3-g{*word-spacing:-.43em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.yui3-g{word-spacing:-.43em}.yui3-u,.yui3-u-1,.yui3-u-1-2,.yui3-u-1-3,.yui3-u-2-3,.yui3-u-1-4,.yui3-u-3-4,.yui3-u-1-5,.yui3-u-2-5,.yui3-u-3-5,.yui3-u-4-5,.yui3-u-1-6,.yui3-u-5-6,.yui3-u-1-8,.yui3-u-3-8,.yui3-u-5-8,.yui3-u-7-8,.yui3-u-1-12,.yui3-u-5-12,.yui3-u-7-12,.yui3-u-11-12,.yui3-u-1-24,.yui3-u-5-24,.yui3-u-7-24,.yui3-u-11-24,.yui3-u-13-24,.yui3-u-17-24,.yui3-u-19-24,.yui3-u-23-24{display:inline-block;text-rendering:auto}.yui3-u-1{display:block}.yui3-g-r{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em}.opera-only :-o-prefocus,.yui3-g-r{word-spacing:-.43em}.yui3-g-r img{max-width:100%}@media(min-width:980px){.yui3-visible-phone,.yui3-visible-tablet,.yui3-hidden-desktop{display:none}}@media(max-width:480px){.yui3-g-r>[class^="yui3-u"]{width:100%}}@media(max-width:767px){.yui3-g-r>[class^="yui3-u"]{width:100%}.yui3-hidden-phone,.yui3-visible-desktop{display:none}}@media(min-width:768px) and (max-width:979px){.yui3-hidden-tablet,.yui3-visible-desktop{display:none}}#yui3-css-stamp.cssgrids-responsive{display:none}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Light.woff") format('woff');font-weight:300}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Regular.woff") format('woff');font-weight:400}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Bold.woff") format('woff');font-weight:700}footer{background:#F7F6F6;clear:both;margin-top:2em;padding:1em}footer p{color:#676767;font-size:13px;font-weight:300}footer .title{margin-bottom:1em}footer .links{margin:0;font-size:13px;padding:0}footer .links a{color:#676767}footer .links a:hover{text-decoration:underline}footer .copyright{color:#bbb}@media all and (min-width:480px){footer{padding:1em 2em}}.yui3-g{letter-spacing:-.31em;*letter-spacing:normal;word-spacing:-.43em}.yui3-u,.yui3-u-1,.yui3-u-1-2,.yui3-u-1-3,.yui3-u-2-3,.yui3-u-1-4,.yui3-u-3-4,.yui3-u-1-5,.yui3-u-2-5,.yui3-u-3-5,.yui3-u-4-5,.yui3-u-1-6,.yui3-u-5-6,.yui3-u-1-8,.yui3-u-3-8,.yui3-u-5-8,.yui3-u-7-8,.yui3-u-1-12,.yui3-u-5-12,.yui3-u-7-12,.yui3-u-11-12,.yui3-u-1-24,.yui3-u-5-24,.yui3-u-7-24,.yui3-u-11-24,.yui3-u-13-24,.yui3-u-17-24,.yui3-u-19-24,.yui3-u-23-24{display:inline-block;zoom:1;*display:inline;letter-spacing:normal;word-spacing:normal;vertical-align:top}.yui3-u-1{display:block}.yui3-u-1-2{width:50%}.yui3-u-1-3{width:33.33333%}.yui3-u-2-3{width:66.66666%}.yui3-u-1-4{width:25%}.yui3-u-3-4{width:75%}.yui3-u-1-5{width:20%}.yui3-u-2-5{width:40%}.yui3-u-3-5{width:60%}.yui3-u-4-5{width:80%}.yui3-u-1-6{width:16.656%}.yui3-u-5-6{width:83.33%}.yui3-u-1-8{width:12.5%}.yui3-u-3-8{width:37.5%}.yui3-u-5-8{width:62.5%}.yui3-u-7-8{width:87.5%}.yui3-u-1-12{width:8.3333%}.yui3-u-5-12{width:41.6666%}.yui3-u-7-12{width:58.3333%}.yui3-u-11-12{width:91.6666%}.yui3-u-1-24{width:4.1666%}.yui3-u-5-24{width:20.8333%}.yui3-u-7-24{width:29.1666%}.yui3-u-11-24{width:45.8333%}.yui3-u-13-24{width:54.1666%}.yui3-u-17-24{width:70.8333%}.yui3-u-19-24{width:79.1666%}.yui3-u-23-24{width:95.8333%}#yui3-css-stamp.cssgrids{display:none}.tooltip-light{background-color:#F3F2F1;border:1px solid #888;color:#3F3F3F;min-width:280px;font-weight:lighter}.tooltip-light p{font-size:1.2em}.tooltip-light p:last-child{margin:0}.tooltip-light .tooltip-title{border-bottom:1px dotted #ccc;padding-bottom:4px;margin-bottom:10px;font-size:18px}.yui3-tooltip .tooltip-light:before{background-color:#F3F2F1}.tooltip{display:none}@media all and (min-width:768px){.tooltip{display:block}label.tooltip{max-width:50%}label.tooltip span{float:right}}select,input,button,textarea,body{font-family:Ubuntu,"Bitstream Vera Sans","DejaVu Sans",Tahoma,sans-serif;color:#333;line-height:1.5;font-weight:300}h1,h2,h3,h4,.u1-h-display,.u1-h-main,h1.main,.u1-h-med,.u1-h-light{font-weight:300;line-height:1.3}h5,h6,.u1-h-small,.u1-h-subhead{font-weight:700}.u1-h-pair{margin-bottom:12px}h1,.u1-h-display{font-size:32px}h2,.u1-h-main,h1.main{font-size:23px}h3,.u1-h-med,.faq-q{font-size:20px}h4,.u1-h-light{font-size:16px}h5,.u1-h-small{font-size:13px}h6,.u1-h-subhead{font-size:12px;text-transform:uppercase}@media all and (min-width:480px){h1,.u1-h-display{font-size:45px}h2,.u1-h-main,h1.main{font-size:32px}h3,.u1-h-med,.faq-q{font-size:23px}h4,.u1-h-light{font-size:20px}h5,.u1-h-small{font-size:16px}h6,.u1-h-subhead{font-size:13px}}p{font-size:16px;margin:0 0 .75em}a,a:link,a:active,a:hover,a:visited{color:#dd4814;text-decoration:none}em,i{font-style:italic}strong,b{font-weight:700}.box{background:0 0 #F7F6F5;border-radius:4px;margin-bottom:3em;padding:0 1em 1em}.box .title{border-bottom:1px dotted #ccc;margin:0 -1em 1em;padding:.5em 1em}.info-items{margin:2em}th.cookie,th.cookie-name{width:15%}th.purpose{width:30%}.legal th,.legal td{padding:.5em;border:1px dotted #ccc}header .wrapper{background:url(../identityprovider/img/dots.png) no-repeat 100% -10px;min-height:34px;overflow:visible}@media all and (min-width:768px){header .wrapper{background:url(../identityprovider/img/dots.png) no-repeat 100% 8px;min-height:64px}}header .wrapper h1{float:left}#ac-status{text-align:right;float:right;margin-top:-10px}#u1-logo{top:-10px;float:left;text-indent:-999em;background:url(../identityprovider/img/u1-small.png) no-repeat left;width:91px;height:33px;position:relative;z-index:1}#u1-logo,.user-name{display:block}@media all and (min-width:480px){#ac-status{margin-bottom:20px;max-width:50%}.user-name{display:inline}}.strapline{margin:0 0 1em;color:#676767}.message:last-child{margin-bottom:1em}.message{border-radius:4px;padding:5px;margin-top:1em;background:#f3f2f1}#missing_backup_device,.unverified-email-warning{margin-bottom:1em}@media all and (min-width:768px){.message{padding:.6em 1em}}.message p:last-child{margin:0}.error{background:#DF382C;color:#fff}.error a{color:#fff;text-decoration:underline}.form-box{background:#F7F7F7;border-top:1px solid #CDCDCD;border-bottom:1px solid #CDCDCD;padding:20px 15px;margin:0 -15px}.form-box .title{border-bottom:1px dotted #D1D1D1;padding-bottom:18px;margin-bottom:1em;line-height:1}.action-title:before{content:" → ";display:inline}a.trusted-rp-name:link,a.trusted-rp-name:active,a.trusted-rp-name:hover,a.trusted-rp-name:focus,a.trusted-rp-name:visited{color:inherit}a.trusted-rp-name:hover{text-decoration:underline}.form-box .input-row,.edit-account-details .input-row{width:inherit}.input-row{width:290px;margin-bottom:20px}.radio-label-row input[type=radio],.radio-label-row input[type=checkbox]{display:inline-block;margin-right:.5em}.radio-label-row label{display:inline-block}input[type=text],input[type=tel],input[type=email],input[type=password]{border:1px solid #AEA79F;padding:.3em;display:block;width:100%;box-sizing:border-box}input[type="text"]:focus,input[type="password"]:focus,input[type="email"]:focus,input[type="url"]:focus,input[type="date"]:focus,input[type="month"]:focus,input[type="time"]:focus,input[type="datetime"]:focus,input[type="datetime-local"]:focus,input[type="week"]:focus,input[type="number"]:focus,input[type="search"]:focus,input[type="tel"]:focus,input[type="color"]:focus,select:focus,textarea:focus{outline:0;outline:thin dotted \9;border-color:#129FEA}.ie8 input[type=text],.ie8 input[type=email]{line-height:1}.ie8 input[type=password]{line-height:1;font-family:Arial,sans-serif}.haserrors input[type=text],.haserrors input[type=email],.haserrors input[type=password]{border:1px solid #DF382C}form .error{color:#DF382C;font-weight:700;font-size:14px;background:0 0}label{display:block;margin-bottom:12px;line-height:1}p>label{line-height:inherit}.form-box .actions{border-top:1px dotted #D1D1D1;padding-top:20px;margin-top:20px}.captcha{margin-top:20px}.accept-tos-input{margin-top:30px;position:relative}.accept-tos-input input{position:absolute;top:5px}.accept-tos-input label{margin-left:20px;line-height:1.5em}.accept-tos-input .error{display:block}.yui3-passwordmeter-indicatorNode div{margin:1em 0 0}.yui3-passwordmeter-indicatorNode p{text-shadow:1px 1px 0 #fff}@media all and (min-width:480px){.form-box{border:1px solid #CDCDCD;border-radius:4px;margin:0 0 1em;padding:18px 22px}}@media all and (min-width:768px){.form-box{margin:0 1em 1em 0;min-width:315px}}.login .cta{margin-right:1em}.new-user,.returning-user{margin-bottom:20px}.readonly .new-user{color:#ccc}.login .forgot-password{display:inline-block;margin-bottom:0}.related-information{margin-top:1em}.recaptcha-noscript{width:100%}.recaptcha-challenge-field{width:100%;box-sizing:border-box}.recaptcha_input_area input{display:inline}.captcha .recaptcha_only_if_privacy{margin-top:-5px}.captchaError #recaptcha_response_field{border:2px solid #c00!important}@media all and (min-width:768px){.recaptcha-noscript{height:330px}}.create-form .input-row{margin-bottom:12px}.create-form .email-input{margin-bottom:20px}.js .create-form.show-no-js{display:none}.dual-forms{position:relative}.js .user-intention i{font-style:normal;display:none}.js .selected-login .login-form{display:block}.js .selected-login .create-form{position:absolute;top:0;right:0;left:0;display:none}.js .selected-login i{display:inline}.selected-login .create-title,.selected-create .login-title{display:none}.js .selected-create .create-form{position:relative;display:block}.js .selected-create .login-form{display:none}.no-js-create-account{border-top:1px dotted #ccc;border-bottom:1px dotted #ccc;padding:1em 0;margin:3em 0;text-align:center;color:#666;text-shadow:1px 1px #fff;font-size:13px}.user-intention span{cursor:pointer}.user-intention input{vertical-align:top}@media all and (min-width:768px){.related-information{margin-left:3em;margin-top:0;border-left:1px dotted #ccc;padding:0 1em}.js .login .returning-user span{display:inline}}.edit-account-details{margin-bottom:2em}.site-date{text-align:right}.listing-section .subtitle{float:right}@media all and (min-width:768px){.edit-account-details input,.edit-account-details select,.edit-account-details .yui3-passwordmeter-content{max-width:50%}.edit-account-details .yui3-passwordmeter-content input{max-width:none}.listing-section{max-width:70%}.listing-section .subtitle{line-height:32px}}.manage-email-adresses{padding-bottom:2em;margin-bottom:2em;border-bottom:1px dotted #ccc}.preferred-email-input select{width:100%}.device-prefs .legend{margin-top:15px;margin-bottom:10px}.delete-button{float:right}.backupdevice-warn-input label{display:inline}.devices-you-added{margin-bottom:2em}.codelist{background-color:#F9F9F9;text-align:center;margin:2em 0}.codelist li{color:#444;font-family:monospace;text-shadow:1px 1px 0 #fff}.codelist li:first-child{padding-top:1em}.codelist li:last-child{padding-bottom:1em}.device-name{font-weight:700}.print-new-codes{float:right}.device-types dt{font-weight:700;list-style:none}.device-types dd{margin-left:18px}.used-applications .subtitle{float:right;position:relative;top:1em}.application-date,.application-date+td{text-align:right}.account-activity table.listing{table-layout:fixed;width:100%}.account-activity thead td{font-weight:700;padding-right:1em}.account-activity tbody td{font-size:80%}.account-activity td.time-date{width:30%;white-space:nowrap;padding-right:1em}.account-activity td.log-type{width:20%;padding-right:1em}.account-activity td.ip-address{width:18%;padding-right:1em}.account-activity td.user-agent{width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.account-activity td.user-agent:hover{overflow:visible;white-space:normal}.preferred-email .email{font-weight:700}.preferred-label{font-style:italic;color:#999}.verified-emails,.unverified-emails{margin-bottom:2em}.menu li span{display:block;border-bottom:1px dotted #ccc;line-height:2.5em;color:#333;padding:0 23px}.menu li .active{background-color:#F7F6F5}.menu li .active:after{content:'▶';font-size:10px;float:right}@media all and (min-width:768px){.menu{width:15em;border-right:1px dotted #ccc;position:absolute;bottom:0;top:102px}.menu li span{padding:0 1em}.with-menu #content{padding-bottom:2em;margin-left:15em}.with-menu footer{margin-top:0}}.account-information{font-weight:300}.account-information h2,.account-information h3{margin-bottom:.5em}.account-faq{float:right}.account-faq li{margin-bottom:.5em}.benefits{border:1px solid #D3D3D3;padding:1.5em;border-radius:4px;margin:40px 0}.benefits .apps,.benefits .music,.benefits .photos,.benefits .cloud{margin-bottom:1em;padding-left:60px;background:url("../identityprovider/img/icons.png") no-repeat 0 50%;height:40px;display:table;line-height:1.2em}.benefits p{display:table-cell;vertical-align:middle}.benefits .apps{background-position:0 -160px}.benefits .music{background-position:0 -200px}.benefits .photos{background-position:0 -240px}.benefits .cloud{background-position:0 -280px}.benefits li:last-child{margin-bottom:0}.questions{margin:0 0 0 1em}.questions li{list-style:disc outside none;margin-bottom:.5em}.more-help{margin:.5em 0 1px}.password-reset-advice{margin-top:5em}.legal h2,.legal h3{margin-bottom:.6em}.legal .content-updates{border-left:2px solid #ccc;padding:2em;margin-bottom:2em}.legal .section{margin-bottom:2em}.legal .account-faq{min-width:350px;margin-left:3em}.faq-q{margin:2em 0 1em}.faq-q::before{content:"Q. "}.faq .faq-q:first-child{margin-top:0}.faq-body ol,.faq-body ul{margin:1em 0;padding:0 0 0 40px}.faq-body ol li{list-style:decimal outside}.faq-body ul li{list-style:circle outside}@media screen and (max-width:756px){.legal .account-faq{min-width:100%;margin-left:0;float:none}}.language-select{margin-bottom:1em}.language-select p{border-bottom:1px dotted #ccc;margin-bottom:.5em;padding-bottom:.5em}.language-select label{display:inline-block;cursor:pointer;margin:0}.language-select button{margin-top:1em}.cannot-find-language{float:right}@media all and (min-width:768px){.readonly{margin-top:56px}.readonly-message{position:fixed;top:0;left:0;right:0;text-align:center;z-index:10;padding:.25em;border-radius:0;margin-top:0}.readonly .readonly-message p{margin:0 auto;max-width:700px}}.question-mark{background-color:#AEA79F;border-radius:100px 100px 100px 100px;color:#fff;cursor:pointer;font-size:1em;font-weight:700;line-height:1.1em;padding:0 .3em}.yui3-hastooltip{cursor:default}.yui3-tooltip{position:absolute;opacity:1;transition:opacity 750ms ease-in-out;padding:10px;max-width:10em}.yui3-tooltip-content{position:relative;background:rgba(30,30,30,1);border-color:rgba(30,30,30,1);border-radius:4px;padding:8px 15px 2px;color:#c8c8c8;font-size:14px;line-height:1.4}div.yui3-tooltip-hidden{opacity:0;visibility:hidden;display:block}.yui3-tooltip .yui3-tooltip-content::before{content:"";position:absolute}.yui3-tooltip-position-north .yui3-tooltip-content::before{bottom:-4px;left:50%;margin-left:-4px;border-top:4px solid #000;border-top-color:inherit;border-left:4px solid transparent;border-right:4px solid transparent}.yui3-tooltip-position-east .yui3-tooltip-content::before{top:50%;left:-4px;margin-top:-4px;border-right:4px solid #000;border-right-color:inherit;border-top:4px solid transparent;border-bottom:4px solid transparent}.yui3-tooltip-position-south .yui3-tooltip-content::before{top:-4px;left:50%;margin-left:-4px;border-bottom:4px solid #000;border-bottom-color:inherit;border-left:4px solid transparent;border-right:4px solid transparent}.yui3-tooltip-position-west .yui3-tooltip-content::before{top:50%;right:-4px;margin-top:-4px;border-left:4px solid #000;border-left-color:inherit;border-top:4px solid transparent;border-bottom:4px solid transparent}td.actions{text-align:right}table.listing{margin:0 0 2em}#content{padding:0 15px}.teams-list{margin:0 0 0 1em}@media all and (min-width:480px){#cont{border:4px solid #E6E3E1;border-width:0 4px}}@media all and (min-width:768px){#cont{border-width:0 16px}#u1-logo{background:url(../identityprovider/img/u1_logo_med.png) no-repeat left;height:60px;width:167px}#content{padding:0 20px}}
290\ No newline at end of file
291+html{color:#000;background:#FFF}body,div,dl,dt{margin:0;padding:0}dd{margin:0}ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea{margin:0;padding:0}p{padding:0}blockquote,th,td{margin:0;padding:0}table{border-spacing:0}fieldset,img{border:0}address,caption,cite,code,dfn{font-style:normal;font-weight:400}em{font-weight:400}strong,th,var{font-style:normal}th,var{font-weight:400}li{list-style:none}caption,th{text-align:left}q:before,q:after{content:''}abbr,acronym{border:0;font-variant:normal}sup{vertical-align:text-top}sub{vertical-align:text-bottom}input,textarea,select{font-size:inherit;font-weight:inherit;*font-size:100%}legend{color:#000}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}html,body{background:#fff}iframe{border:0;background:#EFEDEC}.breadcrumb li{float:left;margin-right:.5em;font-size:16px}.breadcrumb li:after{content:" >"}.breadcrumb li.last:after{content:""}.show-nojs{display:block}.show-ib-nojs{display:inline-block}.show-i-nojs{display:inline}.js .show-nojs,.js .show-ib-nojs,.js .show-i-nojs,.hide-nojs,.hide-ib-nojs,.hide-i-nojs{display:none}.js .hide-nojs{display:block}.js .hide-ib-nojs{display:inline-block}.js .hide-i-nojs,.ie7 .js .hide-ib-nojs{display:inline}.hidden{display:none}.external:hover:after{content:" " url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAAZiS0dEAN0ASAAU7HUIkgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oLBAocL8fy1n0AAADESURBVBjThVAxD8FgFLyv9QMaJIJYEDMxk3TrbDayNNGfoNGfYDAZbUaLTcLMYpOySCWSp+lqqGewfF9L3PYul3t3J5AAOzrjCzLyEXQrfDbaMJy5Isp5BWgyUd5eRS1a4dlrpR19C6lXxdEQYYLTAMBYEIwFodbQwR0XoWkju5kpzpqcQxaJ3QSl5g3RdKCW4Y6Ly/GAKj6i8ylGfQ0RdPcstrGAb4GJiImIX0uP2dGZiDiZXWkdmjYe4zt+7hj18/iHN91rTR+X+JGpAAAAAElFTkSuQmCC")}.subtitle{font-size:13px}::-webkit-input-placeholder{color:#989898}:-moz-placeholder,::-moz-placeholder{color:#989898}:-ms-input-placeholder{color:#989898}.u1-list{margin:1em 0;padding:0}.u1-list li,.u1-list dt{margin:0 0 .3em;padding:0;list-style:inside;list-style-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAHAgMAAABW/tR+AAAAAXNSR0IArs4c6QAAAAlQTFRFhwAFmZmZzMzMmHB+2AAAAAF0Uk5TAEDm2GYAAAABYktHRACIBR1IAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH2wIWFgMB/x6h+AAAABBJREFUCNdjYAADzgQQggEACZYA000oxv4AAAAASUVORK5CYII=)}dt{font-weight:700;display:list-item}dd{padding:0 0 1em}table{width:100%;border:0}table,.ie7 table{border-collapse:collapse}table.gen-listing{table-layout:fixed}.gen-listing th{background-color:#fff;border-top:none;border-bottom-style:solid}.gen-listing td,.gen-listing th{border-width:1px 0}.gen-listing tr:last-child td,.gen-listing tr:last-child th{border-bottom-style:solid}html,body{background:#E6E3E1;height:100%}#cont{max-width:59em;margin:0 auto;background:#fff;position:relative;min-height:100%}header,#content,footer{overflow:hidden}header{position:relative;border-bottom:3px solid #DD4814;padding:20px 15px 15px}@media all and (min-width:480px){header{padding:20px 20px 15px}#cont:after{padding-bottom:150px;display:block;content:" ";clear:both}footer{position:absolute;bottom:0;height:74px;left:0;right:0}}.sidebar #content,.sidebar #content header{padding:0}#content header{padding:5px 0 20px;border:none}#content header.leader{margin:20px -10px}.services #content header.leader,.home #content header.leader{margin-top:0}.services #content header.leader{margin-bottom:40px}.page-title{padding:20px 0 25px}.page-title :last-child{margin-bottom:0}.cta,.cta:link,.cta:visited{color:#fff;padding:.1em .75em;background:#dd4814;background:linear-gradient(#f39455 0%,#ef5e1f 5%,#dd4814 100%);border:1px solid #ad2e03;display:inline-block;text-decoration:none;font-size:108%;line-height:1.5em;border-radius:3px}button.cta{cursor:pointer}.cta:focus,.cta.secondary:focus{border-color:#333;-o-box-shadow:#f7f6f5 0 0 0 1px;box-shadow:#f7f6f5 0 0 0 1px}.cta:hover{background:#f28a45;background:linear-gradient(#f39455 0%,#f28a45 5%,#dd4814 100%)}.cta:focus,.cta:active{color:#fff;background:#dd4814;background:linear-gradient(#dd4814 0%,#bf3b0d 90%,#f39455 100%);border-color:#333}.cta:disabled,.cta.disabled{color:#f9dbd0;color:rgba(255,255,255,.6);border-color:#deab9a;background:#f8bd9d;background:linear-gradient(#f8bd9d 0%,#f6ad8e 5%,#eea489 100%)}.cta.secondary{color:#333;border-color:#aea79f;background:#e6e6e6;background:linear-gradient(#fff 0%,#f7f7f7 5%,#e6e6e6 100%)}.cta.secondary:hover{background:#f7f7f7;background:linear-gradient(#fff 0%,#fff 5%,#e6e6e6 100%)}.cta.secondary:focus,.cta.secondary:active{color:#333;background:#e6e6e6;background:linear-gradient(#e6e6e6 0%,#cdcdcd 90%,#fff 100%)}.cta.secondary.disabled:active,.cta.secondary:disabled{padding:0 10px;color:#b8b8b8;color:rgba(51,51,51,.3);border-color:#cac6c1;background:#fff;background:linear-gradient(#fff 0%,#f9f9f9 5%,#efefef 100%)}section table{border-top:1px solid #ccc;border-bottom:1px solid #ccc}section table tr{border-top:1px dotted #D1D1D1}section table td{padding:.5em 0}section table td:first-child{border-top:none}.yui3-g{*word-spacing:-.43em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.yui3-g{word-spacing:-.43em}.yui3-u,.yui3-u-1,.yui3-u-1-2,.yui3-u-1-3,.yui3-u-2-3,.yui3-u-1-4,.yui3-u-3-4,.yui3-u-1-5,.yui3-u-2-5,.yui3-u-3-5,.yui3-u-4-5,.yui3-u-1-6,.yui3-u-5-6,.yui3-u-1-8,.yui3-u-3-8,.yui3-u-5-8,.yui3-u-7-8,.yui3-u-1-12,.yui3-u-5-12,.yui3-u-7-12,.yui3-u-11-12,.yui3-u-1-24,.yui3-u-5-24,.yui3-u-7-24,.yui3-u-11-24,.yui3-u-13-24,.yui3-u-17-24,.yui3-u-19-24,.yui3-u-23-24{display:inline-block;text-rendering:auto}.yui3-u-1{display:block}.yui3-g-r{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em}.opera-only :-o-prefocus,.yui3-g-r{word-spacing:-.43em}.yui3-g-r img{max-width:100%}@media(min-width:980px){.yui3-visible-phone,.yui3-visible-tablet,.yui3-hidden-desktop{display:none}}@media(max-width:480px){.yui3-g-r>[class^="yui3-u"]{width:100%}}@media(max-width:767px){.yui3-g-r>[class^="yui3-u"]{width:100%}.yui3-hidden-phone,.yui3-visible-desktop{display:none}}@media(min-width:768px) and (max-width:979px){.yui3-hidden-tablet,.yui3-visible-desktop{display:none}}#yui3-css-stamp.cssgrids-responsive{display:none}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Light.woff") format('woff');font-weight:300}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Regular.woff") format('woff');font-weight:400}@font-face{font-family:"Ubuntu";src:url("/assets/fonts/Ubuntu-Bold.woff") format('woff');font-weight:700}footer{background:#F7F6F6;clear:both;margin-top:2em;padding:1em}footer p{color:#676767;font-size:13px;font-weight:300}footer .title{margin-bottom:1em}footer .links{margin:0;font-size:13px;padding:0}footer .links a{color:#676767}footer .links a:hover{text-decoration:underline}footer .copyright{color:#bbb}@media all and (min-width:480px){footer{padding:1em 2em}}.yui3-g{letter-spacing:-.31em;*letter-spacing:normal;word-spacing:-.43em}.yui3-u,.yui3-u-1,.yui3-u-1-2,.yui3-u-1-3,.yui3-u-2-3,.yui3-u-1-4,.yui3-u-3-4,.yui3-u-1-5,.yui3-u-2-5,.yui3-u-3-5,.yui3-u-4-5,.yui3-u-1-6,.yui3-u-5-6,.yui3-u-1-8,.yui3-u-3-8,.yui3-u-5-8,.yui3-u-7-8,.yui3-u-1-12,.yui3-u-5-12,.yui3-u-7-12,.yui3-u-11-12,.yui3-u-1-24,.yui3-u-5-24,.yui3-u-7-24,.yui3-u-11-24,.yui3-u-13-24,.yui3-u-17-24,.yui3-u-19-24,.yui3-u-23-24{display:inline-block;zoom:1;*display:inline;letter-spacing:normal;word-spacing:normal;vertical-align:top}.yui3-u-1{display:block}.yui3-u-1-2{width:50%}.yui3-u-1-3{width:33.33333%}.yui3-u-2-3{width:66.66666%}.yui3-u-1-4{width:25%}.yui3-u-3-4{width:75%}.yui3-u-1-5{width:20%}.yui3-u-2-5{width:40%}.yui3-u-3-5{width:60%}.yui3-u-4-5{width:80%}.yui3-u-1-6{width:16.656%}.yui3-u-5-6{width:83.33%}.yui3-u-1-8{width:12.5%}.yui3-u-3-8{width:37.5%}.yui3-u-5-8{width:62.5%}.yui3-u-7-8{width:87.5%}.yui3-u-1-12{width:8.3333%}.yui3-u-5-12{width:41.6666%}.yui3-u-7-12{width:58.3333%}.yui3-u-11-12{width:91.6666%}.yui3-u-1-24{width:4.1666%}.yui3-u-5-24{width:20.8333%}.yui3-u-7-24{width:29.1666%}.yui3-u-11-24{width:45.8333%}.yui3-u-13-24{width:54.1666%}.yui3-u-17-24{width:70.8333%}.yui3-u-19-24{width:79.1666%}.yui3-u-23-24{width:95.8333%}#yui3-css-stamp.cssgrids{display:none}.tooltip-light{background-color:#F3F2F1;border:1px solid #888;color:#3F3F3F;min-width:280px;font-weight:lighter}.tooltip-light p{font-size:1.2em}.tooltip-light p:last-child{margin:0}.tooltip-light .tooltip-title{border-bottom:1px dotted #ccc;padding-bottom:4px;margin-bottom:10px;font-size:18px}.yui3-tooltip .tooltip-light:before{background-color:#F3F2F1}.tooltip{display:none}@media all and (min-width:768px){.tooltip{display:block}label.tooltip{max-width:50%}label.tooltip span{float:right}}select,input,button,textarea,body{font-family:Ubuntu,"Bitstream Vera Sans","DejaVu Sans",Tahoma,sans-serif;color:#333;line-height:1.5;font-weight:300}h1,h2,h3,h4,.u1-h-display,.u1-h-main,h1.main,.u1-h-med,.u1-h-light{font-weight:300;line-height:1.3}h5,h6,.u1-h-small,.u1-h-subhead{font-weight:700}.u1-h-pair{margin-bottom:12px}h1,.u1-h-display{font-size:32px}h2,.u1-h-main,h1.main{font-size:23px}h3,.u1-h-med,.faq-q{font-size:20px}h4,.u1-h-light{font-size:16px}h5,.u1-h-small{font-size:13px}h6,.u1-h-subhead{font-size:12px;text-transform:uppercase}@media all and (min-width:480px){h1,.u1-h-display{font-size:45px}h2,.u1-h-main,h1.main{font-size:32px}h3,.u1-h-med,.faq-q{font-size:23px}h4,.u1-h-light{font-size:20px}h5,.u1-h-small{font-size:16px}h6,.u1-h-subhead{font-size:13px}}p{font-size:16px;margin:0 0 .75em}a,a:link,a:active,a:hover,a:visited{color:#dd4814;text-decoration:none}em,i{font-style:italic}strong,b{font-weight:700}.box{background:0 0 #F7F6F5;border-radius:4px;margin-bottom:3em;padding:0 1em 1em}.box .title{border-bottom:1px dotted #ccc;margin:0 -1em 1em;padding:.5em 1em}.info-items{margin:2em}th.cookie,th.cookie-name{width:15%}th.purpose{width:30%}.legal th,.legal td{padding:.5em;border:1px dotted #ccc}header .wrapper{background:url(../identityprovider/img/dots.png) no-repeat 100% -10px;min-height:34px;overflow:visible}@media all and (min-width:768px){header .wrapper{background:url(../identityprovider/img/dots.png) no-repeat 100% 8px;min-height:64px}}header .wrapper h1{float:left}#ac-status{text-align:right;float:right;margin-top:-10px}#u1-logo{top:-10px;float:left;text-indent:-999em;background:url(../identityprovider/img/u1-small.png) no-repeat left;width:91px;height:33px;position:relative;z-index:1}#u1-logo,.user-name{display:block}@media all and (min-width:480px){#ac-status{margin-bottom:20px;max-width:50%}.user-name{display:inline}}.strapline{margin:0 0 1em;color:#676767}.message:last-child{margin-bottom:1em}.message{border-radius:4px;padding:5px;margin-top:1em;background:#f3f2f1}#missing_backup_device,.unverified-email-warning{margin-bottom:1em}@media all and (min-width:768px){.message{padding:.6em 1em}}.message p:last-child{margin:0}.error{background:#DF382C;color:#fff}.error a{color:#fff;text-decoration:underline}.form-box{background:#F7F7F7;border-top:1px solid #CDCDCD;border-bottom:1px solid #CDCDCD;padding:20px 15px;margin:0 -15px}.form-box .title{border-bottom:1px dotted #D1D1D1;padding-bottom:18px;margin-bottom:1em;line-height:1}.action-title:before{content:" → ";display:inline}a.trusted-rp-name:link,a.trusted-rp-name:active,a.trusted-rp-name:hover,a.trusted-rp-name:focus,a.trusted-rp-name:visited{color:inherit}a.trusted-rp-name:hover{text-decoration:underline}.form-box .input-row,.edit-account-details .input-row{width:inherit}.input-row{width:290px;margin-bottom:20px}.radio-label-row input[type=radio],.radio-label-row input[type=checkbox]{display:inline-block;margin-right:.5em}.radio-label-row label{display:inline-block}input[type=text],input[type=tel],input[type=email],input[type=password]{border:1px solid #AEA79F;padding:.3em;display:block;width:100%;box-sizing:border-box}input[type="text"]:focus,input[type="password"]:focus,input[type="email"]:focus,input[type="url"]:focus,input[type="date"]:focus,input[type="month"]:focus,input[type="time"]:focus,input[type="datetime"]:focus,input[type="datetime-local"]:focus,input[type="week"]:focus,input[type="number"]:focus,input[type="search"]:focus,input[type="tel"]:focus,input[type="color"]:focus,select:focus,textarea:focus{outline:0;outline:thin dotted \9;border-color:#129FEA}.ie8 input[type=text],.ie8 input[type=email]{line-height:1}.ie8 input[type=password]{line-height:1;font-family:Arial,sans-serif}.haserrors input[type=text],.haserrors input[type=email],.haserrors input[type=password]{border:1px solid #DF382C}form .error{color:#DF382C;font-weight:700;font-size:14px;background:0 0}label{display:block;margin-bottom:12px;line-height:1}p>label{line-height:inherit}.form-box .actions{border-top:1px dotted #D1D1D1;padding-top:20px;margin-top:20px}.captcha{margin-top:20px}.accept-tos-input{margin-top:30px;position:relative}.accept-tos-input input{position:absolute;top:5px}.accept-tos-input label{margin-left:20px;line-height:1.5em}.accept-tos-input .error{display:block}.yui3-passwordmeter-indicatorNode div{margin:1em 0 0}.yui3-passwordmeter-indicatorNode p{text-shadow:1px 1px 0 #fff}@media all and (min-width:480px){.form-box{border:1px solid #CDCDCD;border-radius:4px;margin:0 0 1em;padding:18px 22px}}@media all and (min-width:768px){.form-box{margin:0 1em 1em 0;min-width:315px}}.login .cta{margin-right:1em}.new-user,.returning-user{margin-bottom:20px}.readonly .new-user{color:#ccc}.login .forgot-password{display:inline-block;margin-bottom:0}.related-information{margin-top:1em}.recaptcha-noscript{width:100%}.recaptcha-challenge-field{width:100%;box-sizing:border-box}.recaptcha_input_area input{display:inline}.captcha .recaptcha_only_if_privacy{margin-top:-5px}.captchaError #recaptcha_response_field{border:2px solid #c00!important}@media all and (min-width:768px){.recaptcha-noscript{height:330px}}.create-form .input-row{margin-bottom:12px}.create-form .email-input{margin-bottom:20px}.js .create-form.show-no-js{display:none}.dual-forms{position:relative}.js .user-intention i{font-style:normal;display:none}.js .selected-login .login-form{display:block}.js .selected-login .create-form{position:absolute;top:0;right:0;left:0;display:none}.js .selected-login i{display:inline}.selected-login .create-title,.selected-create .login-title{display:none}.js .selected-create .create-form{position:relative;display:block}.js .selected-create .login-form{display:none}.no-js-create-account{border-top:1px dotted #ccc;border-bottom:1px dotted #ccc;padding:1em 0;margin:3em 0;text-align:center;color:#666;text-shadow:1px 1px #fff;font-size:13px}.user-intention span{cursor:pointer}.user-intention input{vertical-align:top}@media all and (min-width:768px){.related-information{margin-left:3em;margin-top:0;border-left:1px dotted #ccc;padding:0 1em}.js .login .returning-user span{display:inline}}.edit-account-details{margin-bottom:2em}.site-date{text-align:right}.listing-section .subtitle{float:right}@media all and (min-width:768px){.edit-account-details input,.edit-account-details select,.edit-account-details .yui3-passwordmeter-content{max-width:50%}.edit-account-details .yui3-passwordmeter-content input{max-width:none}.listing-section{max-width:70%}.listing-section .subtitle{line-height:32px}}.manage-email-adresses{padding-bottom:2em;margin-bottom:2em;border-bottom:1px dotted #ccc}.preferred-email-input select{width:100%}.device-prefs .legend{margin-top:15px;margin-bottom:10px}.delete-button{float:right}.backupdevice-warn-input label{display:inline}.devices-you-added{margin-bottom:2em}.codelist{background-color:#F9F9F9;text-align:center;margin:2em 0}.codelist li{color:#444;font-family:monospace;text-shadow:1px 1px 0 #fff}.codelist li:first-child{padding-top:1em}.codelist li:last-child{padding-bottom:1em}.device-name{font-weight:700}.print-new-codes{float:right}.device-types dt{font-weight:700;list-style:none}.device-types dd{margin-left:18px}.used-applications .subtitle{float:right;position:relative;top:1em}.application-date,.application-date+td{text-align:right}.account-activity table.listing{table-layout:fixed;width:100%}.account-activity thead td{font-weight:700;padding-right:1em}.account-activity tbody td{font-size:80%}.account-activity td.time-date{width:30%;white-space:nowrap;padding-right:1em}.account-activity td.log-type{width:20%;padding-right:1em}.account-activity td.ip-address{width:18%;padding-right:1em}.account-activity td.user-agent{width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.account-activity td.user-agent:hover{overflow:visible;white-space:normal}.preferred-email .email{font-weight:700}.preferred-label{font-style:italic;color:#999}.verified-emails,.unverified-emails{margin-bottom:2em}.menu li span{display:block;border-bottom:1px dotted #ccc;line-height:2.5em;color:#333;padding:0 23px}.menu li .active{background-color:#F7F6F5}.menu li .active:after{content:'▶';font-size:10px;float:right}@media all and (min-width:768px){.menu{width:15em;border-right:1px dotted #ccc;position:absolute;bottom:0;top:102px}.menu li span{padding:0 1em}.with-menu #content{padding-bottom:2em;margin-left:15em}.with-menu footer{margin-top:0}}.account-information{font-weight:300}.account-information h2,.account-information h3{margin-bottom:.5em}.account-faq{float:right}.account-faq li{margin-bottom:.5em}.benefits{border:1px solid #D3D3D3;padding:1.5em;border-radius:4px;margin:40px 0}.benefits .apps,.benefits .music,.benefits .photos,.benefits .cloud{margin-bottom:1em;padding-left:60px;background:url("../identityprovider/img/icons.png") no-repeat 0 50%;height:40px;display:table;line-height:1.2em}.benefits p{display:table-cell;vertical-align:middle}.benefits .apps{background-position:0 -160px}.benefits .music{background-position:0 -200px}.benefits .photos{background-position:0 -240px}.benefits .cloud{background-position:0 -280px}.benefits li:last-child{margin-bottom:0}.questions{margin:0 0 0 1em}.questions li{list-style:disc outside none;margin-bottom:.5em}.more-help{margin:.5em 0 1px}.password-reset-advice{margin-top:5em}.legal h2,.legal h3{margin-bottom:.6em}.legal .content-updates{border-left:2px solid #ccc;padding:2em;margin-bottom:2em}.legal .section{margin-bottom:2em}.legal .account-faq{min-width:350px;margin-left:3em}.faq-q{margin:2em 0 1em}.faq-q::before{content:"Q. "}.faq .faq-q:first-child{margin-top:0}.faq-body ol,.faq-body ul{margin:1em 0;padding:0 0 0 40px}.faq-body ol li{list-style:decimal outside}.faq-body ul li{list-style:circle outside}@media screen and (max-width:756px){.legal .account-faq{min-width:100%;margin-left:0;float:none}}.language-select{margin-bottom:1em}.language-select p{border-bottom:1px dotted #ccc;margin-bottom:.5em;padding-bottom:.5em}.language-select label{display:inline-block;cursor:pointer;margin:0}.language-select button{margin-top:1em}.cannot-find-language{float:right}@media all and (min-width:768px){.readonly{margin-top:56px}.readonly-message{position:fixed;top:0;left:0;right:0;text-align:center;z-index:10;padding:.25em;border-radius:0;margin-top:0}.readonly .readonly-message p{margin:0 auto;max-width:700px}}.question-mark{background-color:#AEA79F;border-radius:100px 100px 100px 100px;color:#fff;cursor:pointer;font-size:1em;font-weight:700;line-height:1.1em;padding:0 .3em}.yui3-hastooltip{cursor:default}.yui3-tooltip{position:absolute;opacity:1;transition:opacity 750ms ease-in-out;padding:10px;max-width:10em}.yui3-tooltip-content{position:relative;background:rgba(30,30,30,1);border-color:rgba(30,30,30,1);border-radius:4px;padding:8px 15px 2px;color:#c8c8c8;font-size:14px;line-height:1.4}div.yui3-tooltip-hidden{opacity:0;visibility:hidden;display:block}.yui3-tooltip .yui3-tooltip-content::before{content:"";position:absolute}.yui3-tooltip-position-north .yui3-tooltip-content::before{bottom:-4px;left:50%;margin-left:-4px;border-top:4px solid #000;border-top-color:inherit;border-left:4px solid transparent;border-right:4px solid transparent}.yui3-tooltip-position-east .yui3-tooltip-content::before{top:50%;left:-4px;margin-top:-4px;border-right:4px solid #000;border-right-color:inherit;border-top:4px solid transparent;border-bottom:4px solid transparent}.yui3-tooltip-position-south .yui3-tooltip-content::before{top:-4px;left:50%;margin-left:-4px;border-bottom:4px solid #000;border-bottom-color:inherit;border-left:4px solid transparent;border-right:4px solid transparent}.yui3-tooltip-position-west .yui3-tooltip-content::before{top:50%;right:-4px;margin-top:-4px;border-left:4px solid #000;border-left-color:inherit;border-top:4px solid transparent;border-bottom:4px solid transparent}td.actions{text-align:right}table.listing{margin:0 0 2em}#content{padding:0 15px}.teams-list{margin:0 0 0 1em}@media all and (min-width:480px){#cont{border:4px solid #E6E3E1;border-width:0 4px}}@media all and (min-width:768px){#cont{border-width:0 16px}#u1-logo{background:url(../identityprovider/img/u1_logo_med.png) no-repeat left;height:60px;width:167px}#content{padding:0 20px}}div.separated{margin-bottom:2em}h3.separated{margin-bottom:.75em}p.note-separated{margin-top:.5em}div.error blockquote{margin:.5em}
292\ No newline at end of file
293
294=== modified file 'src/identityprovider/static_src/css/ubuntuone.css'
295--- src/identityprovider/static_src/css/ubuntuone.css 2015-09-15 23:16:51 +0000
296+++ src/identityprovider/static_src/css/ubuntuone.css 2016-04-28 00:53:41 +0000
297@@ -986,3 +986,21 @@
298
299
300 }
301+
302+/* GPG Keys ------------------------------------------------------------------*/
303+
304+div.separated {
305+ margin-bottom: 2em;
306+}
307+
308+h3.separated {
309+ margin-bottom: 0.75em;
310+}
311+
312+p.note-separated {
313+ margin-top: 0.5em;
314+}
315+
316+div.error blockquote {
317+ margin: 0.5em;
318+}
319
320=== added file 'src/identityprovider/templates/email/gpg-cleartext-instructions.txt'
321--- src/identityprovider/templates/email/gpg-cleartext-instructions.txt 1970-01-01 00:00:00 +0000
322+++ src/identityprovider/templates/email/gpg-cleartext-instructions.txt 2016-04-28 00:53:41 +0000
323@@ -0,0 +1,17 @@
324+{% load i18n %}
325+{% blocktrans %}
326+This message contains the instructions for confirming registration of an
327+OpenPGP key for use in Ubuntu One. The confirmation instructions have been
328+encrypted with the OpenPGP key you have attempted to register. If you cannot
329+read the unencrypted instructions below, it may be because your mail reader
330+does not support automatic decryption of "ASCII armored" encrypted text.
331+
332+Exact instructions for enabling this depends on the specific mail reader you
333+are using. Please see this support page for more information:{% endblocktrans %}
334+
335+ https://help.launchpad.net/ReadingOpenPgpMail
336+
337+{% blocktrans %}For more general information on OpenPGP and related tools such as Gnu Privacy
338+Guard (GPG), please see:{% endblocktrans %}
339+
340+ https://help.ubuntu.com/community/GnuPrivacyGuardHowto
341
342=== added file 'src/identityprovider/templates/email/validate-gpg.txt'
343--- src/identityprovider/templates/email/validate-gpg.txt 1970-01-01 00:00:00 +0000
344+++ src/identityprovider/templates/email/validate-gpg.txt 2016-04-28 00:53:41 +0000
345@@ -0,0 +1,18 @@
346+{% load i18n %}{% blocktrans %}
347+Here are the instructions for confirming the OpenPGP key registration that we
348+received for use in Ubuntu One.
349+
350+Requester email address: {{requesteremail}}
351+
352+Key details:
353+
354+ Fingerprint : {{fingerprint}}
355+ Key type/ID : {{displayname}}
356+
357+UIDs:
358+{{uids}}
359+
360+Please go here to finish adding the key to your Ubuntu One account:
361+
362+ {{token_url}}
363+{% endblocktrans %}
364
365=== modified file 'src/identityprovider/tests/factory.py'
366--- src/identityprovider/tests/factory.py 2015-11-03 21:20:49 +0000
367+++ src/identityprovider/tests/factory.py 2016-04-28 00:53:41 +0000
368@@ -231,16 +231,25 @@
369
370 def make_authtoken(self, token_type=None, email=None, redirection_url=None,
371 displayname=None, password=None, requester=None,
372- requester_email=None, date_created=None):
373+ requester_email=None, date_created=None,
374+ fingerprint=None):
375 if token_type is None:
376 token_type = AuthTokenType.VALIDATEEMAIL
377+ token_types_with_fingerprints = (
378+ AuthTokenType.VALIDATEGPG,
379+ AuthTokenType.VALIDATESIGNONLYGPG,
380+ )
381+ if token_type in token_types_with_fingerprints:
382+ assert fingerprint is not None, \
383+ "Fingerprint must not be None for this token type."
384 if date_created is None:
385 date_created = now()
386 token = AuthToken.objects.create(
387 token_type=token_type, email=email,
388 redirection_url=redirection_url, displayname=displayname,
389 password=password, requester=requester,
390- requester_email=requester_email, date_created=date_created)
391+ requester_email=requester_email, date_created=date_created,
392+ fingerprint=fingerprint)
393 return token
394
395 def make_leaked_credential(self, email, password, source='source',
396
397=== modified file 'src/identityprovider/tests/test_models_authtoken.py'
398--- src/identityprovider/tests/test_models_authtoken.py 2015-11-10 23:02:07 +0000
399+++ src/identityprovider/tests/test_models_authtoken.py 2016-04-28 00:53:41 +0000
400@@ -18,6 +18,7 @@
401 authtoken,
402 verify_token_string
403 )
404+from identityprovider.models.authtoken import AuthTokenManager
405 from identityprovider.models.const import AuthTokenType
406 from identityprovider.tests.utils import SSOBaseTestCase
407 from identityprovider.utils import generate_random_string
408@@ -47,7 +48,10 @@
409 def test_token_invalid_type(self):
410 good_types = [AuthTokenType.PASSWORDRECOVERY,
411 AuthTokenType.VALIDATEEMAIL,
412- AuthTokenType.INVALIDATEEMAIL]
413+ AuthTokenType.INVALIDATEEMAIL,
414+ AuthTokenType.VALIDATEGPG,
415+ AuthTokenType.VALIDATESIGNONLYGPG,
416+ ]
417 for token_type in good_types:
418 token = AuthToken.objects.create(email='mark@example.com',
419 token_type=token_type)
420@@ -398,3 +402,44 @@
421 token.hashed_token = "unhashedtoken"
422 token.save()
423 self.assertEqual("unhashedtoken", token.short_token)
424+
425+ def test_validation_phrase_for_signonly_token(self):
426+ current_timestamp = now()
427+ fingerprint = 'A' * 40
428+ token = AuthToken.objects.create(
429+ token_type=AuthTokenType.VALIDATESIGNONLYGPG,
430+ requester=self.account,
431+ email=self.email,
432+ date_created=current_timestamp,
433+ fingerprint=fingerprint,
434+ )
435+
436+ expected = (
437+ 'Please register %s to the\n'
438+ 'Ubuntu One user with the email address %s.\n'
439+ '%s') % (
440+ fingerprint,
441+ self.email,
442+ current_timestamp.strftime('%Y-%m-%d %H:%M:%S'))
443+
444+ self.assertEqual(expected, token.validation_phrase)
445+
446+ def test_validation_phrase_raises_for_invalid_token_types(self):
447+ current_timestamp = now()
448+ fingerprint = 'A' * 40
449+ bad_types = [t for t in AuthTokenManager.valid_types
450+ if t != AuthTokenType.VALIDATESIGNONLYGPG]
451+
452+ for token_type in bad_types:
453+ token = AuthToken.objects.create(
454+ token_type=token_type,
455+ requester=self.account,
456+ email=self.email,
457+ date_created=current_timestamp,
458+ fingerprint=fingerprint,
459+ )
460+ expected_message = (
461+ "Invalid token type %d, expected VALIDATESIGNONLYGPG"
462+ % token_type)
463+ with self.assertRaisesMessage(AssertionError, expected_message):
464+ token.validation_phrase
465
466=== modified file 'src/identityprovider/tests/utils.py'
467--- src/identityprovider/tests/utils.py 2016-04-15 03:27:25 +0000
468+++ src/identityprovider/tests/utils.py 2016-04-28 00:53:41 +0000
469@@ -203,6 +203,23 @@
470 service_location, random_key, info_encrypted)
471 return root_macaroon, macaroon_random_key
472
473+ def use_fixture(self, fixture):
474+ """Set up 'fixture' to be used with 'test'.
475+
476+ A poor-man's backport of testtools useFixture for environments where
477+ testtools cannot be used.
478+
479+ Note that this will discard any details the fixture provides, so debug
480+ logs won't be preserved.
481+ """
482+ try:
483+ fixture.setUp()
484+ self.addCleanup(fixture.cleanUp)
485+ except:
486+ fixture.cleanUp()
487+ raise
488+ return fixture
489+
490
491 class SSOBaseTestCase(SSOBaseTestCaseMixin, TestCase):
492
493
494=== added file 'src/webui/forms.py'
495--- src/webui/forms.py 1970-01-01 00:00:00 +0000
496+++ src/webui/forms.py 2016-04-28 00:53:41 +0000
497@@ -0,0 +1,102 @@
498+# Copyright 2016 Canonical Ltd. This software is licensed under
499+# the GNU Affero General Public License version 3 (see the file
500+# LICENSE).
501+
502+from django import forms
503+from django.utils.translation import ugettext_lazy as _
504+from gpgservice_client import (
505+ sanitize_fingerprint,
506+ GPGServiceException,
507+)
508+
509+from webui.gpg import SSOGPGClient
510+from webui.views import gpg_messages
511+from webui.utils import mark_safe_lazy
512+
513+
514+class ClaimOpenPGPKeyForm(forms.Form):
515+
516+ """A form to validate the claiming of an OpenPGP key."""
517+
518+ fingerprint = forms.CharField(
519+ help_text=mark_safe_lazy(
520+ _("For example: {key}").format(
521+ key="<code>27E0 7815 B47C 0397 90D5&nbsp;&nbsp;8589 "
522+ "27D9 A27B F3F9 6058</code>")),
523+ error_messages={'required': gpg_messages.bad_fingerprint_format()},
524+ )
525+ action = forms.CharField(
526+ initial="claim_gpg",
527+ widget=forms.HiddenInput(),
528+ )
529+
530+ def clean_fingerprint(self):
531+ client = SSOGPGClient()
532+ fingerprint = self.cleaned_data['fingerprint']
533+
534+ sane_fingerprint = sanitize_fingerprint(fingerprint)
535+ if sane_fingerprint is None:
536+ raise forms.ValidationError(gpg_messages.bad_fingerprint_format())
537+ if client.getKeyByFingerprint(sane_fingerprint) is not None:
538+ raise forms.ValidationError(
539+ gpg_messages.key_already_imported(fingerprint))
540+
541+ key_details = client.getKeyDetailsFromKeyServer(
542+ sane_fingerprint)
543+ if key_details is None:
544+ raise forms.ValidationError(gpg_messages.unknown_key())
545+ if key_details['expired']:
546+ raise forms.ValidationError(gpg_messages.key_expired(fingerprint))
547+ if key_details['revoked']:
548+ raise forms.ValidationError(gpg_messages.key_revoked(fingerprint))
549+ # The view needs a copy of key_details. To save ourselves an expensive
550+ # round-trip, we expose it here:
551+ self.cleaned_data['key_details'] = key_details
552+ return sane_fingerprint
553+
554+
555+class VerifySignedTextForm(forms.Form):
556+
557+ clearsigned = forms.CharField(
558+ label='Clearsigned Text',
559+ widget=forms.Textarea(),
560+ error_messages={'required': _("Error: Missing signed text.")}
561+ )
562+
563+ def __init__(self, data=None, token=None):
564+ """Validate form based on 'data'.
565+
566+ 'token' should be the authtoken we're claiming.
567+ """
568+ if data and not token:
569+ raise ValueError("'token' must be supplied with 'data'")
570+ super(VerifySignedTextForm, self).__init__(data)
571+ self._token = token
572+
573+ def clean_clearsigned(self):
574+ signed_text = self.cleaned_data['clearsigned'].strip()
575+ if not signed_text:
576+ raise forms.ValidationError(gpg_messages.missing_signed_text())
577+
578+ gpg_client = SSOGPGClient()
579+ try:
580+ fingerprint, content = gpg_client.verifySignedContent(signed_text)
581+ token = self._token
582+ if not compare_gpg_fingerprints(fingerprint, token.fingerprint):
583+ raise forms.ValidationError(
584+ gpg_messages.signing_key_mismatch(fingerprint))
585+
586+ if content.split() != token.validation_phrase.split():
587+ raise forms.ValidationError(
588+ gpg_messages.validation_phrase_mismatch())
589+ except GPGServiceException as e:
590+ raise forms.ValidationError(str(e))
591+ return signed_text
592+
593+
594+def compare_gpg_fingerprints(lhs, rhs):
595+ """Compare two GPG tokens for equality.
596+
597+ Returns True if they are equal, false otherwise.
598+ """
599+ return lhs.replace(' ', '').lower() == rhs.replace(' ', '').lower()
600
601=== added file 'src/webui/gpg.py'
602--- src/webui/gpg.py 1970-01-01 00:00:00 +0000
603+++ src/webui/gpg.py 2016-04-28 00:53:41 +0000
604@@ -0,0 +1,15 @@
605+# Copyright 2016 Canonical Ltd. This software is licensed under
606+# the GNU Affero General Public License version 3 (see the file
607+# LICENSE).
608+
609+"""SSO GPGService Client Implementation."""
610+
611+from gpgservice_client import GPGClient
612+from django.conf import settings
613+
614+
615+class SSOGPGClient(GPGClient):
616+
617+ def __init__(self):
618+ super(SSOGPGClient, self).__init__(
619+ settings.GPGSERVICE_ENDPOINT, settings.GPGSERVICE_TIMEOUT)
620
621=== added file 'src/webui/templates/account/confirm_new_gpg.html'
622--- src/webui/templates/account/confirm_new_gpg.html 1970-01-01 00:00:00 +0000
623+++ src/webui/templates/account/confirm_new_gpg.html 2016-04-28 00:53:41 +0000
624@@ -0,0 +1,33 @@
625+{% extends "base.html" %}
626+{% load i18n %}
627+
628+{% comment %}
629+Copyright 2016 Canonical Ltd. This software is licensed under the
630+GNU Affero General Public License version 3 (see the file LICENSE).
631+{% endcomment %}
632+
633+{% block html_extra %}data-qa-id="confirm_gpg"{% endblock %}
634+
635+{% block title %}{% trans "Complete OpenPGP key validation" %}{% endblock %}
636+
637+{% block text_title %}<h1 class="u1-h-main">{% blocktrans %}Validate OpenPGP Key {{ fingerprint }}?{% endblocktrans %}</h1>{% endblock %}
638+
639+{% block content_id %}auth{% endblock %}
640+
641+{% block content %}
642+<p>{% blocktrans %}Are you sure you want to confirm and validate this OpenPGP key?{% endblocktrans %}</p>
643+
644+<div class="actions">
645+ <form action="" method="post">
646+ {% csrf_token %}
647+ <p>
648+ <input type="hidden" name="post" value="yes" />
649+ <button type="submit" name="continue" class="btn cta" data-qa-id="confirm_gpg">
650+ <span>{% trans "Yes, I'm sure" %}</span>
651+ </button>
652+ </p>
653+ </form>
654+</div>
655+
656+<br style="clear: both" />
657+{% endblock %}
658
659=== added file 'src/webui/templates/account/confirm_new_signonly_gpg.html'
660--- src/webui/templates/account/confirm_new_signonly_gpg.html 1970-01-01 00:00:00 +0000
661+++ src/webui/templates/account/confirm_new_signonly_gpg.html 2016-04-28 00:53:41 +0000
662@@ -0,0 +1,47 @@
663+{% extends "base.html" %}
664+{% load i18n %}
665+
666+{% comment %}
667+Copyright 2016 Canonical Ltd. This software is licensed under the
668+GNU Affero General Public License version 3 (see the file LICENSE).
669+{% endcomment %}
670+
671+{% block html_extra %}data-qa-id="confirm_gpg"{% endblock %}
672+
673+{% block title %}{% trans "Complete OpenPGP key validation" %}{% endblock %}
674+
675+{% block text_title %}<h1 class="u1-h-main">{% blocktrans %}Validate OpenPGP Key {{ fingerprint }}?{% endblocktrans %}</h1>{% endblock %}
676+
677+{% block content_id %}auth{% endblock %}
678+
679+{% block content %}
680+<p>{% blocktrans %}Thanks for adding your OpenPGP key to Ubuntu One. So we can confirm that the key is yours, we need you to use the key to sign some text.{% endblocktrans %}</p>
681+<p>
682+ <strong>{% blocktrans %}Your key's fingerprint:{% endblocktrans %}</strong> <code>{{fingerprint}}</code>
683+</p>
684+<div>
685+ <p>{% blocktrans %}
686+ Please paste a clear-signed copy of the following paragraph
687+ into the box beneath it.{% endblocktrans %}
688+ (<a href="/+help-registry/pgp-key-clearsign.html" target="help">{% trans "How do I do that?" %}</a>)
689+ </p>
690+
691+ <pre>
692+ {{validation_phrase}}
693+ </pre>
694+</div>
695+
696+<div class="actions">
697+ <form action="" method="post">
698+ {% csrf_token %}
699+ <p>
700+ {{ form }}
701+ <button type="submit" name="continue" class="btn cta" data-qa-id="confirm_gpg">
702+ <span>{% trans "Submit Signed Text" %}</span>
703+ </button>
704+ </p>
705+ </form>
706+</div>
707+
708+<br style="clear: both" />
709+{% endblock %}
710
711=== added file 'src/webui/templates/account/gpg_keys.html'
712--- src/webui/templates/account/gpg_keys.html 1970-01-01 00:00:00 +0000
713+++ src/webui/templates/account/gpg_keys.html 2016-04-28 00:53:41 +0000
714@@ -0,0 +1,97 @@
715+{% extends "base.html" %}
716+{% load i18n %}
717+{% comment %}
718+Copyright 2016 Canonical Ltd. This software is licensed under the
719+GNU Affero General Public License version 3 (see the file LICENSE).
720+{% endcomment %}
721+
722+{% block title %}{% trans "GPG Keys" %}{% endblock %}
723+
724+{% block text_title %}
725+ <h1 class="u1-h-main">{% trans "GPG Keys" %}</h1>
726+{% endblock %}
727+
728+{% block content %}
729+<div class="separated">
730+ <h3 class="separated">{% trans "Import an OpenPGP key" %}</h3>
731+ <p>{% blocktrans %}
732+ To start using an OpenPGP key, simply
733+ paste its fingerprint below. The key must be registered with the
734+ Ubuntu key server.
735+ {% endblocktrans %}
736+ {# (<a href="/+help-registry/import-pgp-key.html" target="help">How to get the fingerprint</a>) #}
737+ </p>
738+ <form name="gpg_actions" action="" method="POST">
739+ {% csrf_token %}
740+ {{ claim_form }}
741+ <br />
742+ <p>
743+ Next, Ubuntu One will send email to you at
744+ <code>{{ preferred_email }}</code> with instructions
745+ on finishing the process.
746+ </p>
747+ <input class="cta" type="submit" name="import" value="Import Key"/>
748+ </form>
749+
750+</div>
751+{% if enabled_keys %}
752+ <div class="separated">
753+ <form method="post">
754+ <input type="hidden" name="action" value="deactivate_gpg" />
755+ {% csrf_token %}
756+ <h3 class="separated">{% trans "Your Enabled Keys" %}</h3>
757+ {% for key in enabled_keys %}
758+ <div>
759+ <label>
760+ <input type="checkbox" name="DEACTIVATE_GPGKEY" value="{{key.fingerprint}}"/>
761+ <span>{{key.id}} {% if not key.can_encrypt %}(sign only){% endif %}</span>
762+ </label>
763+ </div>
764+ {% endfor %}
765+ <p class="note-separated">{% blocktrans %}
766+ Disabling a key here disables that key for all
767+ Ubuntu services but does not alter the key outside of
768+ Ubuntu One.
769+ {% endblocktrans %}
770+ </p>
771+ <div><input type="submit" class="cta" value="Disable Key" /></div>
772+ </form>
773+ </div>
774+{% endif %}
775+{% if pending_keys %}
776+<form method="post">
777+ <input type="hidden" name="action" value="remove_gpgtoken" />
778+ {% csrf_token %}
779+ <h2>Keys pending validation</h2>
780+ <div>
781+ {% for token in pending_keys %}
782+ <label>
783+ <input type="checkbox" name="REMOVE_GPGTOKEN" value="{{token.fingerprint}}"/>
784+ <span>{{token.fingerprint}}</span>
785+ </label>
786+ {% endfor %}
787+ </div>
788+ <input type="submit" value="Cancel Validation for Selected Keys" />
789+</form>
790+{% endif %}
791+{% if disabled_keys %}
792+ <div class="separated">
793+ <form method="post">
794+ <input type="hidden" name="action" value="reactivate_gpg" />
795+ {% csrf_token %}
796+ <h3 class="separated">{% trans "Your Disabled Keys" %}</h3>
797+ {% for key in disabled_keys %}
798+ <div>
799+ <label>
800+ <input type="checkbox" name="REACTIVATE_GPGKEY" value="{{key.fingerprint}}"/>
801+ <span>{{key.id}} {% if not key.can_encrypt %}(sign only){% endif %}</span>
802+ </label>
803+ </div>
804+ {% endfor %}
805+ <p>{% trans "You can reactivate any of these keys for use in Ubuntu One whenever you choose." %}</p>
806+ <div><input type="submit" class="cta" value="Enable Key" /></div>
807+ </form>
808+ </div>
809+{% endif %}
810+
811+{% endblock %}
812
813=== modified file 'src/webui/templates/widgets/personal-menu.html'
814--- src/webui/templates/widgets/personal-menu.html 2015-09-04 21:04:12 +0000
815+++ src/webui/templates/widgets/personal-menu.html 2016-04-28 00:53:41 +0000
816@@ -24,6 +24,10 @@
817 {% endifswitch %}
818 {% url 'applications' as applications_url %}
819 {% menu_item "applications" _("Applications") applications_url %}
820+ {% ifswitch GPG_SERVICE %}
821+ {% url 'gpg_keys' as gpg_url %}
822+ {% menu_item "gpg_keys" _("GPG Keys") gpg_url %}
823+ {% endifswitch %}
824 {% url 'auth_log' as auth_log_url %}
825 {% menu_item "auth_log" _("Account Activity") auth_log_url %}
826 {% endif %}
827
828=== added file 'src/webui/tests/test_views_account_gpg.py'
829--- src/webui/tests/test_views_account_gpg.py 1970-01-01 00:00:00 +0000
830+++ src/webui/tests/test_views_account_gpg.py 2016-04-28 00:53:41 +0000
831@@ -0,0 +1,460 @@
832+# Copyright 2010 Canonical Ltd. This software is licensed under the
833+# GNU Affero General Public License version 3 (see the file LICENSE).
834+
835+from __future__ import unicode_literals
836+
837+import random
838+import string
839+import sys
840+import os
841+import re
842+from datetime import datetime
843+from textwrap import dedent
844+
845+from django.core import mail
846+from django.core.urlresolvers import reverse
847+from django.test import override_settings
848+from django.utils.safestring import mark_safe
849+from fixtures import (
850+ EnvironmentVariable,
851+ Fixture,
852+)
853+from gargoyle.testutils import switches
854+from gpgservice_client import (
855+ GPGKeyServiceFixture,
856+ TestKeyServerFixture,
857+)
858+from gpgservice_client.testing.gpg_utils import decrypt_content
859+from gpgservice_client.testing.keyserver.tests.keys import SampleDataKeys
860+
861+from identityprovider.tests.utils import SSOBaseTransactionTestCase
862+from identityprovider.models.authtoken import AuthToken
863+from identityprovider.models.const import AuthTokenType
864+from webui.gpg import SSOGPGClient
865+from webui.views import gpg_messages
866+from webui.forms import compare_gpg_fingerprints
867+
868+
869+def make_form_error_message(text):
870+ """Convert 'text' it to the display format for django's form error message.
871+
872+ Django's assertContains and friends require a complete html fragment,
873+ and there's no easy way to match partial fragments. We have the actual
874+ messages nicely separated in the gpg_messages module, but we can't
875+ test for their existance while they're partial fragments. This
876+ function is a bit of a hack, but makes testing easier.
877+ """
878+ return mark_safe('<li>%s</li>' % text)
879+
880+
881+def make_user_message(text):
882+ """Same as above, but for user messages rather than form error messages."""
883+ return mark_safe('<p>%s</p>' % text)
884+
885+
886+def extract_encrypted_block(text):
887+ """Extract a PGP encrypted block from a larger body of text."""
888+ regex = (
889+ "-----BEGIN PGP MESSAGE-----\n"
890+ "Version: GnuPG v1\n\n"
891+ ".*?"
892+ "-----END PGP MESSAGE-----"
893+ )
894+ match = re.search(regex, text, re.DOTALL)
895+ return text[match.start():match.end()] if match else None
896+
897+
898+def extract_url_from_text(text):
899+ return re.search("(?P<url>https?://[^\s]+)", text).group("url")
900+
901+
902+class SSOGPGServiceFixture(Fixture):
903+
904+ """A fixture to set up and run a test key server and the gpgservice."""
905+
906+ def setUp(self):
907+ super(SSOGPGServiceFixture, self).setUp()
908+ twistd_path = os.path.join(
909+ os.path.dirname(sys.executable), 'twistd')
910+
911+ self.useFixture(EnvironmentVariable('TWISTD_PATH', twistd_path))
912+ self.keyserver = self.useFixture(TestKeyServerFixture())
913+ self.gpgservice = self.useFixture(
914+ GPGKeyServiceFixture(
915+ self.keyserver.bind_host, self.keyserver.daemon_port))
916+ new_settings = override_settings(
917+ GPGSERVICE_ENDPOINT=self.gpgservice.root_url)
918+ new_settings.enable()
919+ self.addCleanup(new_settings.disable)
920+
921+
922+class GPGTestCase(SSOBaseTransactionTestCase):
923+
924+ def assert_fingerprints_are_equal(self, lhs, rhs):
925+ self.assertTrue(compare_gpg_fingerprints(lhs, rhs))
926+
927+ def assert_email_contains_pgp_block(self, email):
928+ self.assertIsNotNone(extract_encrypted_block(email.body))
929+
930+ def assert_email_does_not_contain_pgp_block(self, email):
931+ self.assertIsNone(extract_encrypted_block(email.body))
932+
933+ def create_user_and_login(self, name='person-2'):
934+ password = ''.join(
935+ random.choice(string.ascii_letters) for i in range(16))
936+ account = self.factory.make_account(
937+ password=password,
938+ email='example_user@example.com')
939+ assert self.client.login(
940+ username=account.preferredemail.email, password=password)
941+ return account
942+
943+ def test_can_contact_service(self):
944+ self.use_fixture(SSOGPGServiceFixture())
945+ client = SSOGPGClient()
946+ keys = client.getKeysForOwner('name16_oid')['keys']
947+ self.assertEqual(1, len(keys))
948+
949+ @switches(GPG_SERVICE=False)
950+ def test_gpg_keys_menu_item_not_rendered_without_feature_flag(self):
951+ self.create_user_and_login()
952+ resp = self.client.get(reverse('account-index'))
953+
954+ self.assertNotContains(resp, '<span>GPG Keys</span>', html=True)
955+
956+ @switches(GPG_SERVICE=True)
957+ def test_gpg_keys_menu_item_is_rendered_with_feature_flag(self):
958+ self.create_user_and_login()
959+
960+ resp = self.client.get(reverse('account-index'))
961+
962+ self.assertContains(resp, '<span>GPG Keys</span>', html=True)
963+
964+ @switches(GPG_SERVICE=False)
965+ def test_gpg_keys_view_returns_404_without_feature_flag(self):
966+ self.use_fixture(SSOGPGServiceFixture())
967+ self.create_user_and_login()
968+
969+ resp = self.client.get(reverse('gpg_keys'))
970+ self.assertEqual(404, resp.status_code)
971+
972+ @switches(GPG_SERVICE=True)
973+ def test_gpg_keys_view_returns_200_with_feature_flag(self):
974+ self.use_fixture(SSOGPGServiceFixture())
975+ self.create_user_and_login()
976+
977+ resp = self.client.get(reverse('gpg_keys'))
978+ self.assertEqual(200, resp.status_code)
979+
980+ @switches(GPG_SERVICE=True)
981+ def test_import_with_no_key_errors(self):
982+ self.use_fixture(SSOGPGServiceFixture())
983+ self.create_user_and_login()
984+
985+ resp = self.client.post(
986+ reverse('gpg_keys'),
987+ dict(action='claim_gpg', fingerprint=''))
988+ msg = make_form_error_message(gpg_messages.bad_fingerprint_format())
989+ self.assertContains(resp, msg, html=True)
990+
991+ @switches(GPG_SERVICE=True)
992+ def test_import_with_bad_key_errors(self):
993+ self.use_fixture(SSOGPGServiceFixture())
994+ self.create_user_and_login()
995+
996+ bad_fingerprint = 'A' * 13
997+ resp = self.client.post(
998+ reverse('gpg_keys'),
999+ dict(action='claim_gpg', fingerprint=bad_fingerprint))
1000+ msg = make_form_error_message(gpg_messages.bad_fingerprint_format())
1001+ self.assertContains(resp, msg, html=True)
1002+
1003+ @switches(GPG_SERVICE=True)
1004+ def test_import_existing_key_errors(self):
1005+ self.use_fixture(SSOGPGServiceFixture())
1006+ user = self.create_user_and_login()
1007+ gpg_client = SSOGPGClient()
1008+ key_id = '4C4834CF'
1009+ fingerprint = '8C470B2A0B31568E110D432516281F2E007C98D2'
1010+ keysize = 4096
1011+ algorithm = 'D'
1012+ enabled = True
1013+ can_encrypt = True
1014+ gpg_client.addKeyForTest(
1015+ user.get_openid_identity_url(), key_id, fingerprint, keysize,
1016+ algorithm, enabled, can_encrypt)
1017+
1018+ resp = self.client.post(
1019+ reverse('gpg_keys'),
1020+ dict(action='claim_gpg', fingerprint=fingerprint))
1021+ msg = make_form_error_message(
1022+ gpg_messages.key_already_imported(fingerprint))
1023+ self.assertContains(resp, msg, html=True)
1024+
1025+ @switches(GPG_SERVICE=True)
1026+ def test_import_missing_key_errors(self):
1027+ self.use_fixture(SSOGPGServiceFixture())
1028+ self.create_user_and_login()
1029+ fingerprint = '8C470B2A0B31568E110D432516281F2E007C98D2'
1030+ resp = self.client.post(
1031+ reverse('gpg_keys'),
1032+ dict(action='claim_gpg', fingerprint=fingerprint))
1033+ msg = make_form_error_message(gpg_messages.unknown_key())
1034+ self.assertContains(resp, msg, html=True)
1035+
1036+ @switches(GPG_SERVICE=True)
1037+ def test_import_expired_key_errors(self):
1038+ self.use_fixture(SSOGPGServiceFixture())
1039+ self.create_user_and_login()
1040+ expired_fingerprint = 'ECA5B797586F2E27381A16CFDE6C9167046C6D63'
1041+ resp = self.client.post(
1042+ reverse('gpg_keys'),
1043+ dict(action='claim_gpg', fingerprint=expired_fingerprint))
1044+ msg = make_form_error_message(
1045+ gpg_messages.key_expired(expired_fingerprint))
1046+ self.assertContains(resp, msg, html=True)
1047+
1048+ @switches(GPG_SERVICE=True)
1049+ def test_import_revoked_key_errors(self):
1050+ self.use_fixture(SSOGPGServiceFixture())
1051+ self.create_user_and_login()
1052+ revoked_fingerprint = '84D205F03E1E67096CB54E262BE83793AACCD97C'
1053+ resp = self.client.post(
1054+ reverse('gpg_keys'),
1055+ dict(action='claim_gpg', fingerprint=revoked_fingerprint))
1056+ msg = make_form_error_message(
1057+ gpg_messages.key_revoked(revoked_fingerprint))
1058+ self.assertContains(resp, msg, html=True)
1059+
1060+ @switches(GPG_SERVICE=True)
1061+ def test_claim_encryption_key_workflow(self):
1062+ self.use_fixture(SSOGPGServiceFixture())
1063+ user = self.create_user_and_login()
1064+ encryption_fingerprint = SampleDataKeys.regular_key
1065+ resp = self.client.post(
1066+ reverse('gpg_keys'),
1067+ dict(action='claim_gpg', fingerprint=encryption_fingerprint),
1068+ follow=True)
1069+ condensed_fingerprint = encryption_fingerprint.replace(' ', '')
1070+
1071+ self.assertEqual(200, resp.status_code)
1072+ self.assertEqual(1, len(mail.outbox))
1073+ email = mail.outbox[0]
1074+ self.assert_email_contains_pgp_block(email)
1075+
1076+ pending_tokens = AuthToken.objects.filter(
1077+ fingerprint=condensed_fingerprint)
1078+ self.assertEqual(1, len(pending_tokens))
1079+
1080+ encrypted_block = extract_encrypted_block(email.body)
1081+ plain_text = decrypt_content(encrypted_block.encode('ascii'))
1082+ token_activation_url = extract_url_from_text(plain_text)
1083+
1084+ resp = self.client.get(token_activation_url, follow=True)
1085+ self.assertContains(
1086+ resp,
1087+ "<p>Are you sure you want to confirm and validate this "
1088+ "OpenPGP key?</p>",
1089+ html=True)
1090+
1091+ resp = self.client.post(resp.wsgi_request.get_full_path(), follow=True)
1092+ msg = make_user_message(
1093+ gpg_messages.key_added_to_account(condensed_fingerprint))
1094+ self.assertContains(resp, msg, html=True)
1095+ keys = SSOGPGClient().getKeysForOwner(
1096+ user.get_openid_identity_url())['keys']
1097+ self.assertEqual(1, len(keys))
1098+ self.assert_fingerprints_are_equal(
1099+ keys[0]['fingerprint'], encryption_fingerprint)
1100+
1101+ @switches(GPG_SERVICE=True)
1102+ def test_claim_signonly_key_workflow(self):
1103+ self.use_fixture(SSOGPGServiceFixture())
1104+ user = self.create_user_and_login()
1105+ encryption_fingerprint = SampleDataKeys.sign_only_key
1106+ resp = self.client.post(
1107+ reverse('gpg_keys'),
1108+ dict(action='claim_gpg', fingerprint=encryption_fingerprint),
1109+ follow=True)
1110+ condensed_fingerprint = encryption_fingerprint.replace(' ', '')
1111+
1112+ self.assertEqual(200, resp.status_code)
1113+ self.assertEqual(1, len(mail.outbox))
1114+ email = mail.outbox[0]
1115+ self.assert_email_does_not_contain_pgp_block(email)
1116+
1117+ # Sign-only tokens create the validation phrase from the key
1118+ # fingerprint, name of the person requesting the key, and the
1119+ # datestamp at which the token was created. That last piece of
1120+ # information is unknowable within a test, so let's retrieve the
1121+ # token and change it's creation date:
1122+ token = AuthToken.get_token(
1123+ fingerprint=condensed_fingerprint)
1124+ token.date_created = datetime(2016, 4, 12, 16, 0, 0)
1125+ token.save()
1126+
1127+ token_activation_url = extract_url_from_text(email.body)
1128+
1129+ resp = self.client.get(token_activation_url, follow=True)
1130+ self.assertContains(
1131+ resp,
1132+ "<p>Thanks for adding your OpenPGP key to Ubuntu One. So we can "
1133+ "confirm that the key is yours, we need you to use the key to "
1134+ "sign some text.",
1135+ html=True)
1136+ self.assertContains(
1137+ resp,
1138+ '<pre>Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F to '
1139+ 'the Ubuntu One user with the email address '
1140+ 'example_user@example.com. 2016-04-12 16:00:00</pre>',
1141+ html=True)
1142+
1143+ url = resp.wsgi_request.get_full_path()
1144+ resp = self.client.post(
1145+ url,
1146+ dict(clearsigned=CORRECT_TEXT_SIGNED_WITH_CORRECT_KEY),
1147+ follow=True)
1148+
1149+ msg = make_user_message(
1150+ gpg_messages.key_added_to_account(condensed_fingerprint))
1151+ self.assertContains(resp, msg, html=True)
1152+ keys = SSOGPGClient().getKeysForOwner(
1153+ user.get_openid_identity_url())['keys']
1154+ self.assertEqual(1, len(keys))
1155+ self.assert_fingerprints_are_equal(
1156+ keys[0]['fingerprint'], encryption_fingerprint)
1157+
1158+ def create_signonly_authtoken(self, user, fingerprint):
1159+ return self.factory.make_authtoken(
1160+ token_type=AuthTokenType.VALIDATESIGNONLYGPG,
1161+ email=user.preferredemail.email,
1162+ fingerprint=fingerprint,
1163+ date_created=datetime(2016, 4, 12, 16, 0, 0),
1164+ requester=user,
1165+ )
1166+
1167+ def create_signonly_gpg_token_and_sign(self, fingerprint, signed_text):
1168+ """Create an auth token for a sign-only key, and sign it with 'text'.
1169+
1170+ :param fingerprint: The fingerprint of the GPG key you want to
1171+ register.
1172+ :param signed_text: THe signed text to use when claiming the gpg key.
1173+ :returns: The final reponse object after submitting the claim form.
1174+ """
1175+ self.use_fixture(SSOGPGServiceFixture())
1176+ user = self.create_user_and_login()
1177+ token = self.create_signonly_authtoken(user, fingerprint)
1178+
1179+ args = {
1180+ 'fingerprint': fingerprint,
1181+ 'authtoken': token.raw_token,
1182+ }
1183+ return self.client.post(
1184+ reverse('confirm_signonly_gpg_key', kwargs=args),
1185+ dict(clearsigned=signed_text))
1186+
1187+ def test_claiming_signonly_key_fails_without_signed_text(self):
1188+ resp = self.create_signonly_gpg_token_and_sign(
1189+ SampleDataKeys.sign_only_key, '')
1190+ msg = make_form_error_message(gpg_messages.missing_signed_text())
1191+ self.assertContains(resp, msg, html=True)
1192+
1193+ def test_claiming_signonly_key_fails_when_signed_with_different_key(self):
1194+ resp = self.create_signonly_gpg_token_and_sign(
1195+ SampleDataKeys.sign_only_key,
1196+ CORRECT_TEXT_SIGNED_WITH_DIFFERENT_KEY)
1197+ condensed_key = SampleDataKeys.regular_key.replace(' ', '')
1198+ msg = make_form_error_message(
1199+ gpg_messages.signing_key_mismatch(condensed_key))
1200+ self.assertContains(resp, msg, html=True)
1201+
1202+ def test_claiming_signonly_key_fails_when_signed_text_is_modified(self):
1203+ resp = self.create_signonly_gpg_token_and_sign(
1204+ SampleDataKeys.sign_only_key,
1205+ MODIFIED_TEXT_SIGNED_WITH_CORRECT_KEY)
1206+ msg = make_form_error_message(
1207+ gpg_messages.validation_phrase_mismatch())
1208+ self.assertContains(resp, msg, html=True)
1209+
1210+ def test_claiming_signonly_key_fails_when_signing_with_unknown_key(self):
1211+ resp = self.create_signonly_gpg_token_and_sign(
1212+ SampleDataKeys.sign_only_key, CORRECT_TEXT_SIGNED_WITH_UNKNOWN_KEY)
1213+ self.assertContains(
1214+ resp,
1215+ 'Error: Could not find GPG public key.')
1216+
1217+
1218+# The correct text, signed with the correct key:
1219+CORRECT_TEXT_SIGNED_WITH_CORRECT_KEY = dedent("""\
1220+ -----BEGIN PGP SIGNED MESSAGE-----
1221+ Hash: SHA1
1222+
1223+ Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F to
1224+ the Ubuntu One user with the email address
1225+ example_user@example.com. 2016-04-12 16:00:00
1226+ -----BEGIN PGP SIGNATURE-----
1227+ Version: GnuPG v1
1228+
1229+ iEYEARECAAYFAlcVoAcACgkQfYiRNxewWo8MXQCfVGKwjchBRvzio8Kgpoz7SuDo
1230+ zh0An1Fvx5KP1IEt1ODlPujW9YCENnBT
1231+ =ewCw
1232+ -----END PGP SIGNATURE-----
1233+ """)
1234+
1235+# Correct text signed with a different key (that we know about)
1236+CORRECT_TEXT_SIGNED_WITH_DIFFERENT_KEY = dedent("""\
1237+ -----BEGIN PGP SIGNED MESSAGE-----
1238+ Hash: SHA1
1239+
1240+ Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F
1241+ to the Ubuntu One user person-2. 2016-04-12 16:00:00
1242+ -----BEGIN PGP SIGNATURE-----
1243+ Version: GnuPG v1
1244+
1245+ iEYEARECAAYFAlcNxBAACgkQ2yWXVgK6Xva0awCgmer6hs4htHQZwNcwns4UsEV9
1246+ fh8AoKmMneOkzItHwpju9fDXtP93aAUX
1247+ =ZIGA
1248+ -----END PGP SIGNATURE-----
1249+""")
1250+
1251+# signed with the correct key, but the text was changed:
1252+MODIFIED_TEXT_SIGNED_WITH_CORRECT_KEY = dedent("""\
1253+ -----BEGIN PGP SIGNED MESSAGE-----
1254+ Hash: SHA1
1255+
1256+ Please do not register 447DBF38C4F9C4ED752246B77D88913717B05A8F
1257+ to the Ubuntu One user person-2. 2016-04-12 16:00:00
1258+ -----BEGIN PGP SIGNATURE-----
1259+ Version: GnuPG v1
1260+
1261+ iEYEARECAAYFAlcQGMQACgkQfYiRNxewWo9L6ACgspzv2dOa+HFjxZRXe33cgKyH
1262+ WTUAnRtrZUIULJ9MA3q+G4whKWZ5DW7R
1263+ =BCHM
1264+ -----END PGP SIGNATURE-----
1265+""")
1266+
1267+# The text is correct, but it's signed with an unknown key:
1268+CORRECT_TEXT_SIGNED_WITH_UNKNOWN_KEY = dedent("""\
1269+ -----BEGIN PGP SIGNED MESSAGE-----
1270+ Hash: SHA1
1271+
1272+ Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F
1273+ to the Ubuntu One user person-2. 2016-04-12 16:00:00
1274+ -----BEGIN PGP SIGNATURE-----
1275+ Version: GnuPG v1
1276+
1277+ iQIcBAEBAgAGBQJXEBtaAAoJELCMjm0FjU8pOwQP/18G/Bfezxa1NMaOl4salaR+
1278+ xklj5TASP5hEsbE9etoJlaangsyIDS7b5NLnNX1kz9GfK9K6NIjDpBZGidPMPu6P
1279+ qZ9rnlM1Mi0pxce0a9GJRc65ap3ycMBjeCQBX2cETjkMi5ODGw5R/HZ1Jg0cvM/J
1280+ h8veZ9trdcNN+uUjbpSgti5Q6FswvrxJ10hAadPJCYkSid3pOjSTFDJ9+YoXbLqQ
1281+ bFsq3REaVO1I+aDzEgPUcdOYEuzyE0XK51Z8tYxfwgsHJbxpup0EfzQYvnpUzAYg
1282+ xv5IdDhxhRlT9whcO8TcGvkU3rHch4JIImGe/Tcmf+ftPdemVkiak2O3559g3di+
1283+ UGtvsW7jgKp6z4cZFoPyR0ihW9QU2DH4bDfQ7kl4PS0X9Xmby5Fcr4Dci+iBz/Z+
1284+ g4oeiPvAqIAMRMQu5hgEFNIOOeSNxrTawrDPxZAHYt2Fg/ubF5SW9/0qrWd6AqYQ
1285+ LpCClOQkc7NtL2dXzy37UvDL5ZTrCXicrs4e8JXYtrZTo+chedeDfYk1p1oxiiB8
1286+ X9/USTEMgngdQOpbm2f4nViTK6zLSecMiKLxvzRhuYGKKMXuaPw+qB6+bs7XA590
1287+ 4BP/x7hKGpBJCaBWd/XQStk557PC29EXJVW8e+6N0c8S0hyAhbxoU5KqKuPZvscZ
1288+ 5Sk98yrCrlj23u0vfIhw
1289+ =QwsT
1290+ -----END PGP SIGNATURE-----
1291+""")
1292
1293=== modified file 'src/webui/urls.py'
1294--- src/webui/urls.py 2016-03-10 17:11:43 +0000
1295+++ src/webui/urls.py 2016-04-28 00:53:41 +0000
1296@@ -10,7 +10,8 @@
1297 repls = {
1298 'token': '(?P<token>[A-Za-z0-9]{16})',
1299 'authtoken': '(?P<authtoken>%s)' % AUTHTOKEN_PATTERN,
1300- 'email_address': '(?P<email_address>.+)'
1301+ 'email_address': '(?P<email_address>.+)',
1302+ 'fingerprint': '(?P<fingerprint>.+)',
1303 }
1304
1305 urlpatterns = patterns(
1306@@ -51,6 +52,10 @@
1307 'confirm_email', name='confirm_email'),
1308 url(r'^%(token)s/token/%(authtoken)s/\+newemail/%(email_address)s$' %
1309 repls, 'confirm_email', name='confirm_email'),
1310+ url(r'^token/%(authtoken)s/\+newgpgkey/%(fingerprint)s$' % repls,
1311+ 'confirm_gpg_key', name='confirm_gpg_key'),
1312+ url(r'^token/%(authtoken)s/\+newsignonlygpgkey/%(fingerprint)s$' % repls,
1313+ 'confirm_signonly_gpg_key', name='confirm_signonly_gpg_key'),
1314 url(r'^\+bad-token', 'bad_token', name='bad_token'),
1315 url(r'^\+logout-to-confirm', 'logout_to_confirm',
1316 name='logout_to_confirm'),
1317@@ -126,6 +131,7 @@
1318 name='account_deactivate'),
1319 url(r'^\+delete$', 'delete_account', name='delete_account'),
1320 url(r'^activity$', 'auth_log', name='auth_log'),
1321+ url(r'^gpg-keys$', 'gpg_keys', name='gpg_keys'),
1322 )
1323 # TODO: Support the login-tokens, if it makes sense.
1324 urlpatterns += patterns(
1325
1326=== added file 'src/webui/utils.py'
1327--- src/webui/utils.py 1970-01-01 00:00:00 +0000
1328+++ src/webui/utils.py 2016-04-28 00:53:41 +0000
1329@@ -0,0 +1,10 @@
1330+# Copyright 2016 Canonical Ltd. This software is licensed under
1331+# the GNU Affero General Public License version 3 (see the file
1332+# LICENSE).
1333+from django.utils import six
1334+from django.utils.functional import lazy
1335+from django.utils.safestring import mark_safe
1336+
1337+# https://docs.djangoproject.com/en/1.7/topics/i18n/translation/
1338+# other-uses-of-lazy-in-delayed-translations
1339+mark_safe_lazy = lazy(mark_safe, six.text_type)
1340
1341=== modified file 'src/webui/views/account.py'
1342--- src/webui/views/account.py 2016-02-17 22:52:46 +0000
1343+++ src/webui/views/account.py 2016-04-28 00:53:41 +0000
1344@@ -17,7 +17,9 @@
1345 from django.template import RequestContext
1346 from django.template.loader import render_to_string
1347 from django.template.response import TemplateResponse
1348+from django.utils.timezone import now
1349 from django.views.decorators.vary import vary_on_headers
1350+from gargoyle.decorators import switch_is_active
1351
1352 from openid.yadis.constants import YADIS_HEADER_NAME, YADIS_CONTENT_TYPE
1353
1354@@ -26,6 +28,7 @@
1355 send_invalidation_email_notice,
1356 send_notification_to_invalidated_email_address,
1357 send_validation_email_request,
1358+ send_gpg_validation_message,
1359 )
1360 from identityprovider.forms import (
1361 DeleteAccountForm,
1362@@ -59,6 +62,8 @@
1363
1364 from webui.constants import EMAIL_EXISTS_ERROR, VERIFY_EMAIL_MESSAGE
1365 from webui.decorators import check_readonly, sso_login_required
1366+from webui.forms import ClaimOpenPGPKeyForm
1367+from webui.gpg import SSOGPGClient
1368 from webui.views.const import (
1369 DETAILS_UPDATED,
1370 EMAIL_DELETED,
1371@@ -464,3 +469,55 @@
1372 'auth_log': auth_log_items,
1373 'full_auth_log_length': full_auth_log_length})
1374 return render_to_response('account/auth_log.html', context)
1375+
1376+
1377+@switch_is_active('GPG_SERVICE')
1378+@sso_login_required
1379+@check_readonly
1380+def gpg_keys(request):
1381+ client = SSOGPGClient()
1382+ template_vars = dict(current_section='gpg_keys')
1383+ if request.method == 'POST':
1384+ action = request.POST['action']
1385+ form = ClaimOpenPGPKeyForm(request.POST)
1386+ if action == 'claim_gpg':
1387+ if form.is_valid():
1388+ key_details = form.cleaned_data['key_details']
1389+ if key_details['can_encrypt']:
1390+ token_type = AuthTokenType.VALIDATEGPG
1391+ else:
1392+ token_type = AuthTokenType.VALIDATESIGNONLYGPG
1393+ token = AuthToken.objects.create(
1394+ token_type=token_type,
1395+ email=request.user.preferredemail.email,
1396+ fingerprint=form.cleaned_data['fingerprint'],
1397+ date_created=now(),
1398+ requester=request.user,
1399+ )
1400+ send_gpg_validation_message(
1401+ settings.SSO_ROOT_URL, key_details, token,
1402+ request.user, client)
1403+ return HttpResponseRedirect(reverse('gpg_keys'))
1404+ else:
1405+ form = ClaimOpenPGPKeyForm()
1406+ template_vars['claim_form'] = form
1407+ user_keys = client.getKeysForOwner(
1408+ request.user.get_openid_identity_url())['keys']
1409+ template_vars['pending_keys'] = AuthToken.objects.filter(
1410+ requester=request.user,
1411+ token_type__in=[
1412+ AuthTokenType.VALIDATEGPG,
1413+ AuthTokenType.VALIDATESIGNONLYGPG,
1414+ ]
1415+ )
1416+ template_vars['enabled_keys'] = []
1417+ template_vars['disabled_keys'] = []
1418+ for key in user_keys:
1419+ if key['enabled']:
1420+ template_vars['enabled_keys'].append(key)
1421+ else:
1422+ template_vars['disabled_keys'].append(key)
1423+ template_vars['preferred_email'] = request.user.preferredemail
1424+
1425+ context = RequestContext(request, template_vars)
1426+ return render_to_response('account/gpg_keys.html', context)
1427
1428=== added file 'src/webui/views/gpg_messages.py'
1429--- src/webui/views/gpg_messages.py 1970-01-01 00:00:00 +0000
1430+++ src/webui/views/gpg_messages.py 2016-04-28 00:53:41 +0000
1431@@ -0,0 +1,100 @@
1432+# Copyright 2016 Canonical Ltd. This software is licensed under
1433+# the GNU Affero General Public License version 3 (see the file
1434+# LICENSE).
1435+
1436+"""Build GPG-related error messages for user consumption."""
1437+
1438+from django.utils.html import (
1439+ escape,
1440+ format_html,
1441+)
1442+from django.utils.translation import ugettext_lazy as _
1443+
1444+from webui.utils import mark_safe_lazy
1445+
1446+
1447+def bad_fingerprint_format():
1448+ intro = _("""There seems to be a problem with the fingerprint you
1449+ submitted. You can get your gpg fingerprint by opening a
1450+ terminal and typing:
1451+ {instructions}
1452+ Please try again.
1453+ """)
1454+ html = """
1455+ <blockquote>
1456+ <kbd>gpg --fingerprint</kbd>
1457+ </blockquote>"""
1458+ return mark_safe_lazy(intro.format(instructions=html))
1459+
1460+
1461+def key_already_imported(fingerprint):
1462+ return mark_safe_lazy(
1463+ _('The key {fingerprint} has already been imported.').format(
1464+ fingerprint='<code>%s</code>' % escape(fingerprint)))
1465+
1466+
1467+def unknown_key():
1468+ intro = _("Ubuntu One could not import your OpenPGP key")
1469+ question = _("Did you enter your complete fingerprint correctly?")
1470+ fingerprint_help = _("Help with fingerprints")
1471+ body_text = _(
1472+ "Is your key in the Ubuntu keyserver yet? You may have to wait"
1473+ "between ten minutes (if you pushed directly to the Ubuntu key"
1474+ "server) and one hour (if you pushed your key to another server)."
1475+ )
1476+ publishing_help = _("Help with publishing keys")
1477+
1478+ return format_html(
1479+ """
1480+ <strong>{intro}</strong>
1481+ <ul>
1482+ <li>{question}
1483+ (<a href="http://launchpad.net/+help-registry/import-pgp-key.html">
1484+ {fingerprint_help}</a>)</li>
1485+
1486+ <li>{body_text}
1487+ (<a href="http://launchpad.net/+help-registry/openpgp-keys.html"
1488+ >{publishing_help}</a>)
1489+ </li>
1490+ </ul>
1491+ """,
1492+ intro=intro,
1493+ question=question,
1494+ fingerprint_help=fingerprint_help,
1495+ body_text=body_text,
1496+ publishing_help=publishing_help,
1497+ )
1498+
1499+
1500+def key_expired(fingerprint):
1501+ return mark_safe_lazy(
1502+ _('The key {fingerprint} has expired, and cannot be used.').format(
1503+ fingerprint='<code>%s</code>' % escape(fingerprint)))
1504+
1505+
1506+def key_revoked(fingerprint):
1507+ return mark_safe_lazy(
1508+ _('The key {fingerprint} has been revoked, '
1509+ 'and cannot be used.').format(
1510+ fingerprint='<code>%s</code>' % escape(fingerprint)))
1511+
1512+
1513+def signing_key_mismatch(fingerprint):
1514+ return _(
1515+ 'The key used to sign the content ({fingerprint}) is not the '
1516+ 'key you were registering').format(fingerprint=fingerprint)
1517+
1518+
1519+def validation_phrase_mismatch():
1520+ return _('Error: The signed text does not match the '
1521+ 'validation phrase.')
1522+
1523+
1524+def key_added_to_account(fingerprint):
1525+ return _(
1526+ 'The GPG Key {fingerprint} has been added to your account').format(
1527+ fingerprint=fingerprint)
1528+
1529+
1530+def missing_signed_text():
1531+ return _("Error: Missing signed text.")
1532
1533=== modified file 'src/webui/views/registration.py'
1534--- src/webui/views/registration.py 2016-04-26 18:08:15 +0000
1535+++ src/webui/views/registration.py 2016-04-28 00:53:41 +0000
1536@@ -117,7 +117,6 @@
1537 data['creation_source'] = WEB_CREATION_SOURCE
1538
1539 url = redirection_url_for_token(token)
1540-
1541 try:
1542 api = get_api_client(request)
1543 api.register(**data)
1544
1545=== modified file 'src/webui/views/ui.py'
1546--- src/webui/views/ui.py 2016-03-16 13:04:24 +0000
1547+++ src/webui/views/ui.py 2016-04-28 00:53:41 +0000
1548@@ -82,8 +82,13 @@
1549 requires_cookies,
1550 require_twofactor_enabled,
1551 )
1552-from webui.views.utils import add_captcha_settings
1553-from webui.views import registration
1554+from webui.forms import VerifySignedTextForm
1555+from webui.gpg import SSOGPGClient
1556+from webui.views.utils import add_captcha_settings, require_twofactor
1557+from webui.views import (
1558+ gpg_messages,
1559+ registration,
1560+ )
1561
1562
1563 ACCOUNT_CREATED = _("Your account was created successfully")
1564@@ -433,35 +438,42 @@
1565 def claim_token(request, authtoken):
1566 try:
1567 token = AuthToken.get_token(raw_token=authtoken)
1568- return _handle_confirmation(confirmation_type=token.token_type,
1569- confirmation_code=authtoken,
1570- email=token.email)
1571+ return _handle_confirmation(confirmation_code=authtoken,
1572+ token=token)
1573 except AuthToken.DoesNotExist:
1574 raise Http404()
1575
1576
1577-def _handle_confirmation(
1578- confirmation_type, confirmation_code, email, token=None):
1579- """Handle a confirmation for an action requested on 'email'.
1580+def _handle_confirmation(confirmation_code, token):
1581+ """Handle a confirmation for an action based on 'token'.
1582
1583 Either finish the work in this view, or redirect to a view that can. Also
1584 accepts an optional OpenID-transaction token.
1585
1586 """
1587-
1588+ confirmation_type = token.token_type
1589+ # The 'authtoken' argument is used by all validation views:
1590 args = {
1591 'authtoken': confirmation_code,
1592- 'email_address': email
1593 }
1594- if token is not None:
1595- args['token'] = token
1596
1597+ # If the view we are directing to requires arguments other than 'authtoken'
1598+ # they should be extracted below:
1599 if confirmation_type == AuthTokenType.PASSWORDRECOVERY:
1600 view = 'reset_password'
1601+ args['email_address'] = token.email
1602 elif confirmation_type == AuthTokenType.VALIDATEEMAIL:
1603 view = 'confirm_email'
1604+ args['email_address'] = token.email
1605 elif confirmation_type == AuthTokenType.INVALIDATEEMAIL:
1606 view = 'invalidate_email'
1607+ args['email_address'] = token.email
1608+ elif confirmation_type == AuthTokenType.VALIDATEGPG:
1609+ view = 'confirm_gpg_key'
1610+ args['fingerprint'] = token.fingerprint
1611+ elif confirmation_type == AuthTokenType.VALIDATESIGNONLYGPG:
1612+ view = 'confirm_signonly_gpg_key'
1613+ args['fingerprint'] = token.fingerprint
1614 else:
1615 msg = ('Unknown type %s for confirmation code "%s"' %
1616 (confirmation_type, confirmation_code))
1617@@ -501,16 +513,11 @@
1618 'captcha_required': True}))
1619
1620
1621+@require_twofactor
1622 def confirm_email(request, authtoken, email_address, token=None):
1623 captcha_error_message = ''
1624 captcha_required = gargoyle.is_active('CAPTCHA', request)
1625
1626- if not twofactor.is_authenticated(request):
1627- messages.warning(request,
1628- _('Please log in to use this confirmation code'))
1629- next_url = urlquote(request.get_full_path())
1630- return HttpResponseRedirect('%s?next=%s' % (settings.LOGIN_URL,
1631- next_url))
1632 atrequest = verify_token_string(authtoken, email_address)
1633 if (atrequest is None or
1634 atrequest.token_type != AuthTokenType.VALIDATEEMAIL):
1635@@ -572,6 +579,96 @@
1636 return render(request, 'account/confirm_new_email.html', context)
1637
1638
1639+@require_twofactor
1640+def confirm_gpg_key(request, authtoken, fingerprint):
1641+ atrequest = AuthToken.get_token(
1642+ fingerprint=fingerprint,
1643+ raw_token=authtoken,
1644+ token_type=AuthTokenType.VALIDATEGPG,
1645+ requester=request.user)
1646+ if atrequest is None:
1647+ return HttpResponseRedirect('/+bad-token')
1648+
1649+ email = get_object_or_404(EmailAddress, email__iexact=atrequest.email)
1650+ if request.user.id != email.account.id:
1651+ # The user is authenticated to a different account.
1652+ # Potentially, the token was leaked or intercepted. Let's
1653+ # delete it just in case. The real user can generate another
1654+ # token easily enough.
1655+ atrequest.delete()
1656+ # return a 404 response instead of raising so that
1657+ # the TransactionMiddleware will not rollback the dirty transaction
1658+ return HttpResponseNotFound()
1659+
1660+ if request.method == 'POST':
1661+ gpg_client = SSOGPGClient()
1662+ # only confirm the email address if the form was submitted
1663+ gpg_client.addKeyForOwner(
1664+ request.user.get_openid_identity_url(),
1665+ fingerprint
1666+ )
1667+ atrequest.consume()
1668+ messages.success(
1669+ request, gpg_messages.key_added_to_account(fingerprint))
1670+ return HttpResponseRedirect(
1671+ atrequest.redirection_url or reverse('gpg_keys')
1672+ )
1673+
1674+ # form was not submitted
1675+ context = {
1676+ 'fingerprint': fingerprint,
1677+ }
1678+ return render(request, 'account/confirm_new_gpg.html', context)
1679+
1680+
1681+@require_twofactor
1682+def confirm_signonly_gpg_key(request, authtoken, fingerprint):
1683+ atrequest = AuthToken.get_token(
1684+ fingerprint=fingerprint,
1685+ raw_token=authtoken,
1686+ token_type=AuthTokenType.VALIDATESIGNONLYGPG,
1687+ requester=request.user)
1688+ # atrequest = verify_token_string(authtoken, email_address)
1689+ if atrequest is None:
1690+ return HttpResponseRedirect('/+bad-token')
1691+
1692+ email = get_object_or_404(EmailAddress, email__iexact=atrequest.email)
1693+ if request.user.id != email.account.id:
1694+ # The user is authenticated to a different account.
1695+ # Potentially, the token was leaked or intercepted. Let's
1696+ # delete it just in case. The real user can generate another
1697+ # token easily enough.
1698+ atrequest.delete()
1699+ # return a 404 response instead of raising so that
1700+ # the TransactionMiddleware will not rollback the dirty transaction
1701+ return HttpResponseNotFound()
1702+
1703+ if request.method == 'POST':
1704+ form = VerifySignedTextForm(request.POST, atrequest)
1705+ if form.is_valid():
1706+ gpg_client = SSOGPGClient()
1707+ gpg_client.addKeyForOwner(
1708+ request.user.get_openid_identity_url(),
1709+ fingerprint
1710+ )
1711+ atrequest.consume()
1712+ messages.success(
1713+ request, gpg_messages.key_added_to_account(fingerprint))
1714+ return HttpResponseRedirect(
1715+ atrequest.redirection_url or reverse('gpg_keys')
1716+ )
1717+ else:
1718+ form = VerifySignedTextForm()
1719+
1720+ # form was not submitted
1721+ context = {
1722+ 'fingerprint': fingerprint,
1723+ 'validation_phrase': atrequest.validation_phrase,
1724+ 'form': form,
1725+ }
1726+ return render(request, 'account/confirm_new_signonly_gpg.html', context)
1727+
1728+
1729 def bad_token(request):
1730 return TemplateResponse(request, 'bad_token.html')
1731
1732
1733=== modified file 'src/webui/views/utils.py'
1734--- src/webui/views/utils.py 2015-05-08 19:25:27 +0000
1735+++ src/webui/views/utils.py 2016-04-28 00:53:41 +0000
1736@@ -7,8 +7,13 @@
1737 from functools import wraps
1738
1739 from django.conf import settings
1740+from django.contrib import messages
1741 from django.http import HttpResponseNotAllowed, HttpResponseRedirect
1742 from django.template.response import TemplateResponse
1743+from django.utils.http import urlquote
1744+from django.utils.translation import ugettext_lazy as _
1745+
1746+from identityprovider.models import twofactor
1747
1748
1749 class HttpResponseSeeOther(HttpResponseRedirect):
1750@@ -50,3 +55,43 @@
1751 'CAPTCHA_API_URL_SECURE': settings.CAPTCHA_API_URL_SECURE}
1752 d.update(context)
1753 return d
1754+
1755+
1756+def require_twofactor(view_fn):
1757+ """A view decorator that requires that the user be authenticated with 2fa.
1758+
1759+ For a given view function like this::
1760+
1761+ def my_view(request, some, other, optional, args):
1762+ ...
1763+
1764+ You can use this decorator like so::
1765+
1766+ @require_twofactor
1767+ def my_view(request, some, other, optional, args):
1768+ ...
1769+
1770+ This is exactly equivilent to writing the following::
1771+
1772+ def my_view(request, some, other, optional, args):
1773+ if not twofactor.is_authenticated(request):
1774+ messages.warning(
1775+ request,
1776+ _('Please log in to use this confirmation code'))
1777+ next_url = urlquote(request.get_full_path())
1778+ return HttpResponseRedirect(
1779+ '%s?next=%s' % (settings.LOGIN_URL, next_url))
1780+
1781+ Since this is a pattern used in several places, it's worth abstracting into
1782+ a re-usable decorator.
1783+ """
1784+ @wraps(view_fn)
1785+ def wrapper(request, *args, **kwargs):
1786+ if not twofactor.is_authenticated(request):
1787+ messages.warning(request,
1788+ _('Please log in to use this confirmation code'))
1789+ next_url = urlquote(request.get_full_path())
1790+ return HttpResponseRedirect('%s?next=%s' % (settings.LOGIN_URL,
1791+ next_url))
1792+ return view_fn(request, *args, **kwargs)
1793+ return wrapper