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
=== modified file 'README'
--- README 2016-03-11 15:00:46 +0000
+++ README 2016-04-28 00:53:41 +0000
@@ -7,7 +7,7 @@
77
80. The supported way to develop Canonical Identity Provider is using LXC's.80. The supported way to develop Canonical Identity Provider is using LXC's.
9There's some additional documentation on LXC gotchas in the following9There's some additional documentation on LXC gotchas in the following
10page (check there specially if you can't resolve the lxc address when 10page (check there specially if you can't resolve the lxc address when
11ssh'ing and if you want to open GUI apps seeing them in your host machine):11ssh'ing and if you want to open GUI apps seeing them in your host machine):
1212
13 https://wiki.canonical.com/UbuntuOne/Developer/LXC13 https://wiki.canonical.com/UbuntuOne/Developer/LXC
@@ -209,23 +209,30 @@
209 Note: this is two separate steps as in Mojo specs we need to separate the209 Note: this is two separate steps as in Mojo specs we need to separate the
210 dependency collect step from the build step, as the build step runs in an210 dependency collect step from the build step, as the build step runs in an
211 lxc with no internet access at all.211 lxc with no internet access at all.
212 212
213213
21413. (Optional) Set up private/public keys to make macaroons work between 21413. (Optional) Set up private/public keys to make macaroons work between
215 projects 215 projects
216216
217 For some endpoints to work correctly, the system needs to decrypt keys217 For some endpoints to work correctly, the system needs to decrypt keys
218 that were encrypted from other services (you'll need to use this 218 that were encrypted from other services (you'll need to use this
219 instructions, or the corresponding one in the other projects).219 instructions, or the corresponding one in the other projects).
220220
221 So, create a pair of keys for this project:221 So, create a pair of keys for this project:
222222
223 ssh-keygen -t rsa -N "" -f project_id_rsa223 ssh-keygen -t rsa -N "" -f project_id_rsa
224224
225 This will leave you with two files, move the private one into the 225 This will leave you with two files, move the private one into the
226 ``rsakeys`` directory, and the .pub one into this same dir in the226 ``rsakeys`` directory, and the .pub one into this same dir in the
227 other projects.227 other projects.
228228
22914. (Optional) Configure the GPG service endpoint.
230
231 This is required if you want to use any of the gpg-related features in SSO.
232 You'll need to run a gpgservice instance somewhere (instructions are in
233 the project's root), and set the 'GPGSERVICE_ENDPOINT' setting in your
234 settings.py to point to the IP_address:tcp_port pair where the service is
235 running.
229236
230BAZAAR237BAZAAR
231------238------
232239
=== modified file 'django_project/settings_base.py'
--- django_project/settings_base.py 2016-04-25 18:31:45 +0000
+++ django_project/settings_base.py 2016-04-28 00:53:41 +0000
@@ -184,6 +184,8 @@
184GARGOYLE_SWITCH_DEFAULTS = {}184GARGOYLE_SWITCH_DEFAULTS = {}
185GEOIP_PATH = '/usr/share/GeoIP'185GEOIP_PATH = '/usr/share/GeoIP'
186GOOGLE_ANALYTICS_ID = 'THE-GOOGLE-ID'186GOOGLE_ANALYTICS_ID = 'THE-GOOGLE-ID'
187GPGSERVICE_ENDPOINT = None
188GPGSERVICE_TIMEOUT = 5
187HANDLER_TIMEOUT_MILLIS = 2500189HANDLER_TIMEOUT_MILLIS = 2500
188HONEYPOT_FIELD_NAME = 'openid.usernamesecret'190HONEYPOT_FIELD_NAME = 'openid.usernamesecret'
189HOTP_BACKWARDS_DRIFT = 0191HOTP_BACKWARDS_DRIFT = 0
190192
=== modified file 'django_project/settings_devel.py'
--- django_project/settings_devel.py 2016-04-04 15:38:38 +0000
+++ django_project/settings_devel.py 2016-04-28 00:53:41 +0000
@@ -26,6 +26,7 @@
26 'CAPTCHA_NEW_ACCOUNT': {'is_active': False},26 'CAPTCHA_NEW_ACCOUNT': {'is_active': False},
27 'PREFLIGHT': {'is_active': True},27 'PREFLIGHT': {'is_active': True},
28 'LOGIN_BY_TOKEN': {'is_active': True},28 'LOGIN_BY_TOKEN': {'is_active': True},
29 'GPG_SERVICE': {'is_active': True},
29}30}
30PASSWORD_HASHERS = [31PASSWORD_HASHERS = [
31 'django.contrib.auth.hashers.SHA1PasswordHasher',32 'django.contrib.auth.hashers.SHA1PasswordHasher',
3233
=== modified file 'requirements.txt'
--- requirements.txt 2016-03-14 17:21:32 +0000
+++ requirements.txt 2016-04-28 00:53:41 +0000
@@ -33,3 +33,4 @@
33timeline==0.0.433timeline==0.0.4
34timeline-django==0.0.434timeline-django==0.0.4
35whitenoise==2.0.635whitenoise==2.0.6
36gpgservice-client==0.0.3
3637
=== modified file 'requirements_devel.txt'
--- requirements_devel.txt 2016-04-11 16:49:59 +0000
+++ requirements_devel.txt 2016-04-28 00:53:41 +0000
@@ -6,6 +6,7 @@
6extras==0.0.36extras==0.0.3
7fixtures==0.3.127fixtures==0.3.12
8flake8==2.4.08flake8==2.4.0
9gpgservice==0.1.3
9junitxml==0.710junitxml==0.7
10localmail==0.311localmail==0.3
11logilab-astng==0.24.312logilab-astng==0.24.3
@@ -24,6 +25,7 @@
24sst==0.2.5dev25sst==0.2.5dev
25testscenarios==0.426testscenarios==0.4
26testtools==0.9.3927testtools==0.9.39
28txfixtures==0.1.4
27u1-test-utils==0.529u1-test-utils==0.5
28ucitests==0.4.130ucitests==0.4.1
29wsgi-intercept==0.5.131wsgi-intercept==0.5.1
3032
=== modified file 'src/identityprovider/emailutils.py'
--- src/identityprovider/emailutils.py 2016-01-13 02:42:16 +0000
+++ src/identityprovider/emailutils.py 2016-04-28 00:53:41 +0000
@@ -10,7 +10,10 @@
10from django.template import RequestContext10from django.template import RequestContext
11from django.template.loader import render_to_string11from django.template.loader import render_to_string
12from django.utils.timezone import now12from django.utils.timezone import now
13from django.utils.translation import ugettext_lazy as _13from django.utils.translation import (
14 ugettext,
15 ugettext_lazy as _,
16)
1417
15from identityprovider.models import AuthToken, EmailAddress18from identityprovider.models import AuthToken, EmailAddress
16from identityprovider.models.const import AuthTokenType, EmailStatus19from identityprovider.models.const import AuthTokenType, EmailStatus
@@ -381,3 +384,76 @@
381 to = [format_address(e)384 to = [format_address(e)
382 for e in settings.TWOFACTOR_FAILURE_NOTIFICATION_EMAILS]385 for e in settings.TWOFACTOR_FAILURE_NOTIFICATION_EMAILS]
383 return send_mail(subject, msg, from_email, to)386 return send_mail(subject, msg, from_email, to)
387
388
389def send_gpg_validation_message(root_url, key_details, token, requester,
390 gpg_client):
391 """Craft the confirmation message that will be sent to the user.
392
393 There are two chunks of text that will be concatenated together into a
394 single text/plain part. The first chunk will be the clear text
395 instructions providing some extra help for those people who cannot
396 read the encrypted chunk that follows. The encrypted chunk will
397 have the actual confirmation token in it, however the ability to
398 read this is highly dependent on the mail reader being used, and how
399 that MUA is configured.
400
401 :param root_url: The root URL this instance of SSO is hosted at
402 :param key_details: A dictionary of PGP key details, as returned by
403 SSOGPGClient.getKeyDetailsFromKeyServer(...)
404 :param token: The AuthToken instance that was generated when the GPG key
405 was claimed.
406 :param requester: The Account instance that initiated the gpg claim.
407 :param gpg_client: An instance of SSOGPGClient.
408
409 :raises AssertionError: If the token's type is not either VALIDATEGPG or
410 VALIDATESIGNONLYGPG.
411 """
412 assert token.token_type in (
413 AuthTokenType.VALIDATEGPG, AuthTokenType.VALIDATESIGNONLYGPG), (
414 "Cannot send gpg validation email for token type %d. Expected"
415 "VALIDATEGPG or VALIDATESIGNONLYGPG" % token.token_type)
416 separator = '\n '
417 formatted_uids = ' ' + separator.join(key_details['emails'])
418
419 # Here are the instructions that need to be encrypted.
420 template = 'email/validate-gpg.txt'
421 context = {
422 'requester': requester.displayname,
423 'requesteremail': requester.preferredemail,
424 'displayname': key_details['displayname'],
425 'fingerprint': key_details['fingerprint'],
426 'uids': formatted_uids,
427 'token_url': urljoin(root_url, token.get_absolute_url()),
428 }
429
430 context = RequestContext(None, context)
431 token_text = render_to_string(template, context_instance=context)
432
433 # These strings aren't lazily translated since we need to render them
434 # just a few lines further down this function.
435 salutation = ugettext('Hello,\n\n')
436 instructions = ''
437 closing = ugettext('Thanks,\n\nThe Ubuntu One Team')
438
439 # Encrypt this part's content if requested.
440 if key_details['can_encrypt']:
441 token_text = gpg_client.encryptContent(
442 key_details['fingerprint'], token_text.encode('utf-8'))
443 # In this case, we need to include some clear text instructions
444 # for people who do not have an MUA that can decrypt the ASCII
445 # armored text.
446 instructions = render_to_string(
447 'email/gpg-cleartext-instructions.txt', context)
448
449 # Concatenate the message parts and send it.
450 text = salutation + instructions + token_text + closing
451 from_name = 'Ubuntu One OpenPGP Key Confirmation'
452 from_address = format_address(settings.NOREPLY_FROM_ADDRESS, from_name)
453 subject = 'Ubuntu One: Confirm your OpenPGP Key'
454 send_mail(
455 subject,
456 text,
457 from_address,
458 [format_address(requester.preferredemail.email)]
459 )
384460
=== added file 'src/identityprovider/migrations/0009_authtoken_gpg_columns.py'
--- src/identityprovider/migrations/0009_authtoken_gpg_columns.py 1970-01-01 00:00:00 +0000
+++ src/identityprovider/migrations/0009_authtoken_gpg_columns.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,19 @@
1# -*- coding: utf-8 -*-
2from __future__ import unicode_literals
3
4from django.db import migrations, models
5
6
7class Migration(migrations.Migration):
8
9 dependencies = [
10 ('identityprovider', '0008_accountpassword_date_changed'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='authtoken',
16 name='fingerprint',
17 field=models.TextField(null=True, blank=True),
18 ),
19 ]
020
=== modified file 'src/identityprovider/models/account.py'
--- src/identityprovider/models/account.py 2016-03-23 15:57:01 +0000
+++ src/identityprovider/models/account.py 2016-04-28 00:53:41 +0000
@@ -213,6 +213,10 @@
213 def get_absolute_url(self):213 def get_absolute_url(self):
214 return reverse('server-identity', args=[self.openid_identifier])214 return reverse('server-identity', args=[self.openid_identifier])
215215
216 def get_openid_identity_url(self):
217 """Get the full openid identity url."""
218 return settings.SSO_ROOT_URL + self.get_absolute_url()
219
216 @property220 @property
217 def need_backup_device_warning(self):221 def need_backup_device_warning(self):
218 return self.warn_about_backup_device and self.devices.count() == 1222 return self.warn_about_backup_device and self.devices.count() == 1
219223
=== modified file 'src/identityprovider/models/authtoken.py'
--- src/identityprovider/models/authtoken.py 2016-03-14 20:44:58 +0000
+++ src/identityprovider/models/authtoken.py 2016-04-28 00:53:41 +0000
@@ -58,6 +58,8 @@
58 AuthTokenType.INVALIDATEEMAIL,58 AuthTokenType.INVALIDATEEMAIL,
59 AuthTokenType.VALIDATEEMAIL,59 AuthTokenType.VALIDATEEMAIL,
60 AuthTokenType.PASSWORDRECOVERY,60 AuthTokenType.PASSWORDRECOVERY,
61 AuthTokenType.VALIDATEGPG,
62 AuthTokenType.VALIDATESIGNONLYGPG,
61 ]63 ]
6264
63 def create(self, **kwargs):65 def create(self, **kwargs):
@@ -108,6 +110,8 @@
108 displayname = DisplaynameField(null=True, blank=True)110 displayname = DisplaynameField(null=True, blank=True)
109 password = PasswordField(null=True, blank=True)111 password = PasswordField(null=True, blank=True)
110112
113 fingerprint = models.TextField(blank=True)
114
111 objects = AuthTokenManager()115 objects = AuthTokenManager()
112116
113 class Meta:117 class Meta:
@@ -204,6 +208,25 @@
204 token.date_consumed = right_now208 token.date_consumed = right_now
205 token.save()209 token.save()
206210
211 @property
212 def validation_phrase(self):
213 """Get a validation phrase for this authtoken.
214
215 AuthToken objects for sign-only keys require the user to sign a text
216 phrase with the key they claim they own.
217
218 :raises AssertionError: if the token_type is not VALIDATESIGNONLYGPG.
219 """
220 assert self.token_type == AuthTokenType.VALIDATESIGNONLYGPG, \
221 "Invalid token type %d, expected VALIDATESIGNONLYGPG" \
222 % self.token_type
223 return ('Please register %s to the\n'
224 'Ubuntu One user with the email address %s.\n'
225 '%s') % (
226 self.fingerprint,
227 self.requester.preferredemail,
228 self.date_created.strftime('%Y-%m-%d %H:%M:%S'))
229
207230
208def create_unique_token_for_table(token_length=AUTHTOKEN_LENGTH,231def create_unique_token_for_table(token_length=AUTHTOKEN_LENGTH,
209 obj=AuthToken, column='hashed_token'):232 obj=AuthToken, column='hashed_token'):
210233
=== modified file 'src/identityprovider/static/css/all.css'
--- src/identityprovider/static/css/all.css 2015-12-15 13:05:44 +0000
+++ src/identityprovider/static/css/all.css 2016-04-28 00:53:41 +0000
@@ -1,1 +1,1 @@
1html{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}}
2\ No newline at end of file1\ No newline at end of file
2html{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}
3\ No newline at end of file3\ No newline at end of file
44
=== modified file 'src/identityprovider/static_src/css/ubuntuone.css'
--- src/identityprovider/static_src/css/ubuntuone.css 2015-09-15 23:16:51 +0000
+++ src/identityprovider/static_src/css/ubuntuone.css 2016-04-28 00:53:41 +0000
@@ -986,3 +986,21 @@
986986
987987
988}988}
989
990/* GPG Keys ------------------------------------------------------------------*/
991
992div.separated {
993 margin-bottom: 2em;
994}
995
996h3.separated {
997 margin-bottom: 0.75em;
998}
999
1000p.note-separated {
1001 margin-top: 0.5em;
1002}
1003
1004div.error blockquote {
1005 margin: 0.5em;
1006}
9891007
=== added file 'src/identityprovider/templates/email/gpg-cleartext-instructions.txt'
--- src/identityprovider/templates/email/gpg-cleartext-instructions.txt 1970-01-01 00:00:00 +0000
+++ src/identityprovider/templates/email/gpg-cleartext-instructions.txt 2016-04-28 00:53:41 +0000
@@ -0,0 +1,17 @@
1{% load i18n %}
2{% blocktrans %}
3This message contains the instructions for confirming registration of an
4OpenPGP key for use in Ubuntu One. The confirmation instructions have been
5encrypted with the OpenPGP key you have attempted to register. If you cannot
6read the unencrypted instructions below, it may be because your mail reader
7does not support automatic decryption of "ASCII armored" encrypted text.
8
9Exact instructions for enabling this depends on the specific mail reader you
10are using. Please see this support page for more information:{% endblocktrans %}
11
12 https://help.launchpad.net/ReadingOpenPgpMail
13
14{% blocktrans %}For more general information on OpenPGP and related tools such as Gnu Privacy
15Guard (GPG), please see:{% endblocktrans %}
16
17 https://help.ubuntu.com/community/GnuPrivacyGuardHowto
018
=== added file 'src/identityprovider/templates/email/validate-gpg.txt'
--- src/identityprovider/templates/email/validate-gpg.txt 1970-01-01 00:00:00 +0000
+++ src/identityprovider/templates/email/validate-gpg.txt 2016-04-28 00:53:41 +0000
@@ -0,0 +1,18 @@
1{% load i18n %}{% blocktrans %}
2Here are the instructions for confirming the OpenPGP key registration that we
3received for use in Ubuntu One.
4
5Requester email address: {{requesteremail}}
6
7Key details:
8
9 Fingerprint : {{fingerprint}}
10 Key type/ID : {{displayname}}
11
12UIDs:
13{{uids}}
14
15Please go here to finish adding the key to your Ubuntu One account:
16
17 {{token_url}}
18{% endblocktrans %}
019
=== modified file 'src/identityprovider/tests/factory.py'
--- src/identityprovider/tests/factory.py 2015-11-03 21:20:49 +0000
+++ src/identityprovider/tests/factory.py 2016-04-28 00:53:41 +0000
@@ -231,16 +231,25 @@
231231
232 def make_authtoken(self, token_type=None, email=None, redirection_url=None,232 def make_authtoken(self, token_type=None, email=None, redirection_url=None,
233 displayname=None, password=None, requester=None,233 displayname=None, password=None, requester=None,
234 requester_email=None, date_created=None):234 requester_email=None, date_created=None,
235 fingerprint=None):
235 if token_type is None:236 if token_type is None:
236 token_type = AuthTokenType.VALIDATEEMAIL237 token_type = AuthTokenType.VALIDATEEMAIL
238 token_types_with_fingerprints = (
239 AuthTokenType.VALIDATEGPG,
240 AuthTokenType.VALIDATESIGNONLYGPG,
241 )
242 if token_type in token_types_with_fingerprints:
243 assert fingerprint is not None, \
244 "Fingerprint must not be None for this token type."
237 if date_created is None:245 if date_created is None:
238 date_created = now()246 date_created = now()
239 token = AuthToken.objects.create(247 token = AuthToken.objects.create(
240 token_type=token_type, email=email,248 token_type=token_type, email=email,
241 redirection_url=redirection_url, displayname=displayname,249 redirection_url=redirection_url, displayname=displayname,
242 password=password, requester=requester,250 password=password, requester=requester,
243 requester_email=requester_email, date_created=date_created)251 requester_email=requester_email, date_created=date_created,
252 fingerprint=fingerprint)
244 return token253 return token
245254
246 def make_leaked_credential(self, email, password, source='source',255 def make_leaked_credential(self, email, password, source='source',
247256
=== modified file 'src/identityprovider/tests/test_models_authtoken.py'
--- src/identityprovider/tests/test_models_authtoken.py 2015-11-10 23:02:07 +0000
+++ src/identityprovider/tests/test_models_authtoken.py 2016-04-28 00:53:41 +0000
@@ -18,6 +18,7 @@
18 authtoken,18 authtoken,
19 verify_token_string19 verify_token_string
20)20)
21from identityprovider.models.authtoken import AuthTokenManager
21from identityprovider.models.const import AuthTokenType22from identityprovider.models.const import AuthTokenType
22from identityprovider.tests.utils import SSOBaseTestCase23from identityprovider.tests.utils import SSOBaseTestCase
23from identityprovider.utils import generate_random_string24from identityprovider.utils import generate_random_string
@@ -47,7 +48,10 @@
47 def test_token_invalid_type(self):48 def test_token_invalid_type(self):
48 good_types = [AuthTokenType.PASSWORDRECOVERY,49 good_types = [AuthTokenType.PASSWORDRECOVERY,
49 AuthTokenType.VALIDATEEMAIL,50 AuthTokenType.VALIDATEEMAIL,
50 AuthTokenType.INVALIDATEEMAIL]51 AuthTokenType.INVALIDATEEMAIL,
52 AuthTokenType.VALIDATEGPG,
53 AuthTokenType.VALIDATESIGNONLYGPG,
54 ]
51 for token_type in good_types:55 for token_type in good_types:
52 token = AuthToken.objects.create(email='mark@example.com',56 token = AuthToken.objects.create(email='mark@example.com',
53 token_type=token_type)57 token_type=token_type)
@@ -398,3 +402,44 @@
398 token.hashed_token = "unhashedtoken"402 token.hashed_token = "unhashedtoken"
399 token.save()403 token.save()
400 self.assertEqual("unhashedtoken", token.short_token)404 self.assertEqual("unhashedtoken", token.short_token)
405
406 def test_validation_phrase_for_signonly_token(self):
407 current_timestamp = now()
408 fingerprint = 'A' * 40
409 token = AuthToken.objects.create(
410 token_type=AuthTokenType.VALIDATESIGNONLYGPG,
411 requester=self.account,
412 email=self.email,
413 date_created=current_timestamp,
414 fingerprint=fingerprint,
415 )
416
417 expected = (
418 'Please register %s to the\n'
419 'Ubuntu One user with the email address %s.\n'
420 '%s') % (
421 fingerprint,
422 self.email,
423 current_timestamp.strftime('%Y-%m-%d %H:%M:%S'))
424
425 self.assertEqual(expected, token.validation_phrase)
426
427 def test_validation_phrase_raises_for_invalid_token_types(self):
428 current_timestamp = now()
429 fingerprint = 'A' * 40
430 bad_types = [t for t in AuthTokenManager.valid_types
431 if t != AuthTokenType.VALIDATESIGNONLYGPG]
432
433 for token_type in bad_types:
434 token = AuthToken.objects.create(
435 token_type=token_type,
436 requester=self.account,
437 email=self.email,
438 date_created=current_timestamp,
439 fingerprint=fingerprint,
440 )
441 expected_message = (
442 "Invalid token type %d, expected VALIDATESIGNONLYGPG"
443 % token_type)
444 with self.assertRaisesMessage(AssertionError, expected_message):
445 token.validation_phrase
401446
=== modified file 'src/identityprovider/tests/utils.py'
--- src/identityprovider/tests/utils.py 2016-04-15 03:27:25 +0000
+++ src/identityprovider/tests/utils.py 2016-04-28 00:53:41 +0000
@@ -203,6 +203,23 @@
203 service_location, random_key, info_encrypted)203 service_location, random_key, info_encrypted)
204 return root_macaroon, macaroon_random_key204 return root_macaroon, macaroon_random_key
205205
206 def use_fixture(self, fixture):
207 """Set up 'fixture' to be used with 'test'.
208
209 A poor-man's backport of testtools useFixture for environments where
210 testtools cannot be used.
211
212 Note that this will discard any details the fixture provides, so debug
213 logs won't be preserved.
214 """
215 try:
216 fixture.setUp()
217 self.addCleanup(fixture.cleanUp)
218 except:
219 fixture.cleanUp()
220 raise
221 return fixture
222
206223
207class SSOBaseTestCase(SSOBaseTestCaseMixin, TestCase):224class SSOBaseTestCase(SSOBaseTestCaseMixin, TestCase):
208225
209226
=== added file 'src/webui/forms.py'
--- src/webui/forms.py 1970-01-01 00:00:00 +0000
+++ src/webui/forms.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,102 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).
4
5from django import forms
6from django.utils.translation import ugettext_lazy as _
7from gpgservice_client import (
8 sanitize_fingerprint,
9 GPGServiceException,
10)
11
12from webui.gpg import SSOGPGClient
13from webui.views import gpg_messages
14from webui.utils import mark_safe_lazy
15
16
17class ClaimOpenPGPKeyForm(forms.Form):
18
19 """A form to validate the claiming of an OpenPGP key."""
20
21 fingerprint = forms.CharField(
22 help_text=mark_safe_lazy(
23 _("For example: {key}").format(
24 key="<code>27E0 7815 B47C 0397 90D5&nbsp;&nbsp;8589 "
25 "27D9 A27B F3F9 6058</code>")),
26 error_messages={'required': gpg_messages.bad_fingerprint_format()},
27 )
28 action = forms.CharField(
29 initial="claim_gpg",
30 widget=forms.HiddenInput(),
31 )
32
33 def clean_fingerprint(self):
34 client = SSOGPGClient()
35 fingerprint = self.cleaned_data['fingerprint']
36
37 sane_fingerprint = sanitize_fingerprint(fingerprint)
38 if sane_fingerprint is None:
39 raise forms.ValidationError(gpg_messages.bad_fingerprint_format())
40 if client.getKeyByFingerprint(sane_fingerprint) is not None:
41 raise forms.ValidationError(
42 gpg_messages.key_already_imported(fingerprint))
43
44 key_details = client.getKeyDetailsFromKeyServer(
45 sane_fingerprint)
46 if key_details is None:
47 raise forms.ValidationError(gpg_messages.unknown_key())
48 if key_details['expired']:
49 raise forms.ValidationError(gpg_messages.key_expired(fingerprint))
50 if key_details['revoked']:
51 raise forms.ValidationError(gpg_messages.key_revoked(fingerprint))
52 # The view needs a copy of key_details. To save ourselves an expensive
53 # round-trip, we expose it here:
54 self.cleaned_data['key_details'] = key_details
55 return sane_fingerprint
56
57
58class VerifySignedTextForm(forms.Form):
59
60 clearsigned = forms.CharField(
61 label='Clearsigned Text',
62 widget=forms.Textarea(),
63 error_messages={'required': _("Error: Missing signed text.")}
64 )
65
66 def __init__(self, data=None, token=None):
67 """Validate form based on 'data'.
68
69 'token' should be the authtoken we're claiming.
70 """
71 if data and not token:
72 raise ValueError("'token' must be supplied with 'data'")
73 super(VerifySignedTextForm, self).__init__(data)
74 self._token = token
75
76 def clean_clearsigned(self):
77 signed_text = self.cleaned_data['clearsigned'].strip()
78 if not signed_text:
79 raise forms.ValidationError(gpg_messages.missing_signed_text())
80
81 gpg_client = SSOGPGClient()
82 try:
83 fingerprint, content = gpg_client.verifySignedContent(signed_text)
84 token = self._token
85 if not compare_gpg_fingerprints(fingerprint, token.fingerprint):
86 raise forms.ValidationError(
87 gpg_messages.signing_key_mismatch(fingerprint))
88
89 if content.split() != token.validation_phrase.split():
90 raise forms.ValidationError(
91 gpg_messages.validation_phrase_mismatch())
92 except GPGServiceException as e:
93 raise forms.ValidationError(str(e))
94 return signed_text
95
96
97def compare_gpg_fingerprints(lhs, rhs):
98 """Compare two GPG tokens for equality.
99
100 Returns True if they are equal, false otherwise.
101 """
102 return lhs.replace(' ', '').lower() == rhs.replace(' ', '').lower()
0103
=== added file 'src/webui/gpg.py'
--- src/webui/gpg.py 1970-01-01 00:00:00 +0000
+++ src/webui/gpg.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,15 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).
4
5"""SSO GPGService Client Implementation."""
6
7from gpgservice_client import GPGClient
8from django.conf import settings
9
10
11class SSOGPGClient(GPGClient):
12
13 def __init__(self):
14 super(SSOGPGClient, self).__init__(
15 settings.GPGSERVICE_ENDPOINT, settings.GPGSERVICE_TIMEOUT)
016
=== added file 'src/webui/templates/account/confirm_new_gpg.html'
--- src/webui/templates/account/confirm_new_gpg.html 1970-01-01 00:00:00 +0000
+++ src/webui/templates/account/confirm_new_gpg.html 2016-04-28 00:53:41 +0000
@@ -0,0 +1,33 @@
1{% extends "base.html" %}
2{% load i18n %}
3
4{% comment %}
5Copyright 2016 Canonical Ltd. This software is licensed under the
6GNU Affero General Public License version 3 (see the file LICENSE).
7{% endcomment %}
8
9{% block html_extra %}data-qa-id="confirm_gpg"{% endblock %}
10
11{% block title %}{% trans "Complete OpenPGP key validation" %}{% endblock %}
12
13{% block text_title %}<h1 class="u1-h-main">{% blocktrans %}Validate OpenPGP Key {{ fingerprint }}?{% endblocktrans %}</h1>{% endblock %}
14
15{% block content_id %}auth{% endblock %}
16
17{% block content %}
18<p>{% blocktrans %}Are you sure you want to confirm and validate this OpenPGP key?{% endblocktrans %}</p>
19
20<div class="actions">
21 <form action="" method="post">
22 {% csrf_token %}
23 <p>
24 <input type="hidden" name="post" value="yes" />
25 <button type="submit" name="continue" class="btn cta" data-qa-id="confirm_gpg">
26 <span>{% trans "Yes, I'm sure" %}</span>
27 </button>
28 </p>
29 </form>
30</div>
31
32<br style="clear: both" />
33{% endblock %}
034
=== added file 'src/webui/templates/account/confirm_new_signonly_gpg.html'
--- src/webui/templates/account/confirm_new_signonly_gpg.html 1970-01-01 00:00:00 +0000
+++ src/webui/templates/account/confirm_new_signonly_gpg.html 2016-04-28 00:53:41 +0000
@@ -0,0 +1,47 @@
1{% extends "base.html" %}
2{% load i18n %}
3
4{% comment %}
5Copyright 2016 Canonical Ltd. This software is licensed under the
6GNU Affero General Public License version 3 (see the file LICENSE).
7{% endcomment %}
8
9{% block html_extra %}data-qa-id="confirm_gpg"{% endblock %}
10
11{% block title %}{% trans "Complete OpenPGP key validation" %}{% endblock %}
12
13{% block text_title %}<h1 class="u1-h-main">{% blocktrans %}Validate OpenPGP Key {{ fingerprint }}?{% endblocktrans %}</h1>{% endblock %}
14
15{% block content_id %}auth{% endblock %}
16
17{% block content %}
18<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>
19<p>
20 <strong>{% blocktrans %}Your key's fingerprint:{% endblocktrans %}</strong> <code>{{fingerprint}}</code>
21</p>
22<div>
23 <p>{% blocktrans %}
24 Please paste a clear-signed copy of the following paragraph
25 into the box beneath it.{% endblocktrans %}
26 (<a href="/+help-registry/pgp-key-clearsign.html" target="help">{% trans "How do I do that?" %}</a>)
27 </p>
28
29 <pre>
30 {{validation_phrase}}
31 </pre>
32</div>
33
34<div class="actions">
35 <form action="" method="post">
36 {% csrf_token %}
37 <p>
38 {{ form }}
39 <button type="submit" name="continue" class="btn cta" data-qa-id="confirm_gpg">
40 <span>{% trans "Submit Signed Text" %}</span>
41 </button>
42 </p>
43 </form>
44</div>
45
46<br style="clear: both" />
47{% endblock %}
048
=== added file 'src/webui/templates/account/gpg_keys.html'
--- src/webui/templates/account/gpg_keys.html 1970-01-01 00:00:00 +0000
+++ src/webui/templates/account/gpg_keys.html 2016-04-28 00:53:41 +0000
@@ -0,0 +1,97 @@
1{% extends "base.html" %}
2{% load i18n %}
3{% comment %}
4Copyright 2016 Canonical Ltd. This software is licensed under the
5GNU Affero General Public License version 3 (see the file LICENSE).
6{% endcomment %}
7
8{% block title %}{% trans "GPG Keys" %}{% endblock %}
9
10{% block text_title %}
11 <h1 class="u1-h-main">{% trans "GPG Keys" %}</h1>
12{% endblock %}
13
14{% block content %}
15<div class="separated">
16 <h3 class="separated">{% trans "Import an OpenPGP key" %}</h3>
17 <p>{% blocktrans %}
18 To start using an OpenPGP key, simply
19 paste its fingerprint below. The key must be registered with the
20 Ubuntu key server.
21 {% endblocktrans %}
22 {# (<a href="/+help-registry/import-pgp-key.html" target="help">How to get the fingerprint</a>) #}
23 </p>
24 <form name="gpg_actions" action="" method="POST">
25 {% csrf_token %}
26 {{ claim_form }}
27 <br />
28 <p>
29 Next, Ubuntu One will send email to you at
30 <code>{{ preferred_email }}</code> with instructions
31 on finishing the process.
32 </p>
33 <input class="cta" type="submit" name="import" value="Import Key"/>
34 </form>
35
36</div>
37{% if enabled_keys %}
38 <div class="separated">
39 <form method="post">
40 <input type="hidden" name="action" value="deactivate_gpg" />
41 {% csrf_token %}
42 <h3 class="separated">{% trans "Your Enabled Keys" %}</h3>
43 {% for key in enabled_keys %}
44 <div>
45 <label>
46 <input type="checkbox" name="DEACTIVATE_GPGKEY" value="{{key.fingerprint}}"/>
47 <span>{{key.id}} {% if not key.can_encrypt %}(sign only){% endif %}</span>
48 </label>
49 </div>
50 {% endfor %}
51 <p class="note-separated">{% blocktrans %}
52 Disabling a key here disables that key for all
53 Ubuntu services but does not alter the key outside of
54 Ubuntu One.
55 {% endblocktrans %}
56 </p>
57 <div><input type="submit" class="cta" value="Disable Key" /></div>
58 </form>
59 </div>
60{% endif %}
61{% if pending_keys %}
62<form method="post">
63 <input type="hidden" name="action" value="remove_gpgtoken" />
64 {% csrf_token %}
65 <h2>Keys pending validation</h2>
66 <div>
67 {% for token in pending_keys %}
68 <label>
69 <input type="checkbox" name="REMOVE_GPGTOKEN" value="{{token.fingerprint}}"/>
70 <span>{{token.fingerprint}}</span>
71 </label>
72 {% endfor %}
73 </div>
74 <input type="submit" value="Cancel Validation for Selected Keys" />
75</form>
76{% endif %}
77{% if disabled_keys %}
78 <div class="separated">
79 <form method="post">
80 <input type="hidden" name="action" value="reactivate_gpg" />
81 {% csrf_token %}
82 <h3 class="separated">{% trans "Your Disabled Keys" %}</h3>
83 {% for key in disabled_keys %}
84 <div>
85 <label>
86 <input type="checkbox" name="REACTIVATE_GPGKEY" value="{{key.fingerprint}}"/>
87 <span>{{key.id}} {% if not key.can_encrypt %}(sign only){% endif %}</span>
88 </label>
89 </div>
90 {% endfor %}
91 <p>{% trans "You can reactivate any of these keys for use in Ubuntu One whenever you choose." %}</p>
92 <div><input type="submit" class="cta" value="Enable Key" /></div>
93 </form>
94 </div>
95{% endif %}
96
97{% endblock %}
098
=== modified file 'src/webui/templates/widgets/personal-menu.html'
--- src/webui/templates/widgets/personal-menu.html 2015-09-04 21:04:12 +0000
+++ src/webui/templates/widgets/personal-menu.html 2016-04-28 00:53:41 +0000
@@ -24,6 +24,10 @@
24 {% endifswitch %}24 {% endifswitch %}
25 {% url 'applications' as applications_url %}25 {% url 'applications' as applications_url %}
26 {% menu_item "applications" _("Applications") applications_url %}26 {% menu_item "applications" _("Applications") applications_url %}
27 {% ifswitch GPG_SERVICE %}
28 {% url 'gpg_keys' as gpg_url %}
29 {% menu_item "gpg_keys" _("GPG Keys") gpg_url %}
30 {% endifswitch %}
27 {% url 'auth_log' as auth_log_url %}31 {% url 'auth_log' as auth_log_url %}
28 {% menu_item "auth_log" _("Account Activity") auth_log_url %}32 {% menu_item "auth_log" _("Account Activity") auth_log_url %}
29{% endif %}33{% endif %}
3034
=== added file 'src/webui/tests/test_views_account_gpg.py'
--- src/webui/tests/test_views_account_gpg.py 1970-01-01 00:00:00 +0000
+++ src/webui/tests/test_views_account_gpg.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,460 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import unicode_literals
5
6import random
7import string
8import sys
9import os
10import re
11from datetime import datetime
12from textwrap import dedent
13
14from django.core import mail
15from django.core.urlresolvers import reverse
16from django.test import override_settings
17from django.utils.safestring import mark_safe
18from fixtures import (
19 EnvironmentVariable,
20 Fixture,
21)
22from gargoyle.testutils import switches
23from gpgservice_client import (
24 GPGKeyServiceFixture,
25 TestKeyServerFixture,
26)
27from gpgservice_client.testing.gpg_utils import decrypt_content
28from gpgservice_client.testing.keyserver.tests.keys import SampleDataKeys
29
30from identityprovider.tests.utils import SSOBaseTransactionTestCase
31from identityprovider.models.authtoken import AuthToken
32from identityprovider.models.const import AuthTokenType
33from webui.gpg import SSOGPGClient
34from webui.views import gpg_messages
35from webui.forms import compare_gpg_fingerprints
36
37
38def make_form_error_message(text):
39 """Convert 'text' it to the display format for django's form error message.
40
41 Django's assertContains and friends require a complete html fragment,
42 and there's no easy way to match partial fragments. We have the actual
43 messages nicely separated in the gpg_messages module, but we can't
44 test for their existance while they're partial fragments. This
45 function is a bit of a hack, but makes testing easier.
46 """
47 return mark_safe('<li>%s</li>' % text)
48
49
50def make_user_message(text):
51 """Same as above, but for user messages rather than form error messages."""
52 return mark_safe('<p>%s</p>' % text)
53
54
55def extract_encrypted_block(text):
56 """Extract a PGP encrypted block from a larger body of text."""
57 regex = (
58 "-----BEGIN PGP MESSAGE-----\n"
59 "Version: GnuPG v1\n\n"
60 ".*?"
61 "-----END PGP MESSAGE-----"
62 )
63 match = re.search(regex, text, re.DOTALL)
64 return text[match.start():match.end()] if match else None
65
66
67def extract_url_from_text(text):
68 return re.search("(?P<url>https?://[^\s]+)", text).group("url")
69
70
71class SSOGPGServiceFixture(Fixture):
72
73 """A fixture to set up and run a test key server and the gpgservice."""
74
75 def setUp(self):
76 super(SSOGPGServiceFixture, self).setUp()
77 twistd_path = os.path.join(
78 os.path.dirname(sys.executable), 'twistd')
79
80 self.useFixture(EnvironmentVariable('TWISTD_PATH', twistd_path))
81 self.keyserver = self.useFixture(TestKeyServerFixture())
82 self.gpgservice = self.useFixture(
83 GPGKeyServiceFixture(
84 self.keyserver.bind_host, self.keyserver.daemon_port))
85 new_settings = override_settings(
86 GPGSERVICE_ENDPOINT=self.gpgservice.root_url)
87 new_settings.enable()
88 self.addCleanup(new_settings.disable)
89
90
91class GPGTestCase(SSOBaseTransactionTestCase):
92
93 def assert_fingerprints_are_equal(self, lhs, rhs):
94 self.assertTrue(compare_gpg_fingerprints(lhs, rhs))
95
96 def assert_email_contains_pgp_block(self, email):
97 self.assertIsNotNone(extract_encrypted_block(email.body))
98
99 def assert_email_does_not_contain_pgp_block(self, email):
100 self.assertIsNone(extract_encrypted_block(email.body))
101
102 def create_user_and_login(self, name='person-2'):
103 password = ''.join(
104 random.choice(string.ascii_letters) for i in range(16))
105 account = self.factory.make_account(
106 password=password,
107 email='example_user@example.com')
108 assert self.client.login(
109 username=account.preferredemail.email, password=password)
110 return account
111
112 def test_can_contact_service(self):
113 self.use_fixture(SSOGPGServiceFixture())
114 client = SSOGPGClient()
115 keys = client.getKeysForOwner('name16_oid')['keys']
116 self.assertEqual(1, len(keys))
117
118 @switches(GPG_SERVICE=False)
119 def test_gpg_keys_menu_item_not_rendered_without_feature_flag(self):
120 self.create_user_and_login()
121 resp = self.client.get(reverse('account-index'))
122
123 self.assertNotContains(resp, '<span>GPG Keys</span>', html=True)
124
125 @switches(GPG_SERVICE=True)
126 def test_gpg_keys_menu_item_is_rendered_with_feature_flag(self):
127 self.create_user_and_login()
128
129 resp = self.client.get(reverse('account-index'))
130
131 self.assertContains(resp, '<span>GPG Keys</span>', html=True)
132
133 @switches(GPG_SERVICE=False)
134 def test_gpg_keys_view_returns_404_without_feature_flag(self):
135 self.use_fixture(SSOGPGServiceFixture())
136 self.create_user_and_login()
137
138 resp = self.client.get(reverse('gpg_keys'))
139 self.assertEqual(404, resp.status_code)
140
141 @switches(GPG_SERVICE=True)
142 def test_gpg_keys_view_returns_200_with_feature_flag(self):
143 self.use_fixture(SSOGPGServiceFixture())
144 self.create_user_and_login()
145
146 resp = self.client.get(reverse('gpg_keys'))
147 self.assertEqual(200, resp.status_code)
148
149 @switches(GPG_SERVICE=True)
150 def test_import_with_no_key_errors(self):
151 self.use_fixture(SSOGPGServiceFixture())
152 self.create_user_and_login()
153
154 resp = self.client.post(
155 reverse('gpg_keys'),
156 dict(action='claim_gpg', fingerprint=''))
157 msg = make_form_error_message(gpg_messages.bad_fingerprint_format())
158 self.assertContains(resp, msg, html=True)
159
160 @switches(GPG_SERVICE=True)
161 def test_import_with_bad_key_errors(self):
162 self.use_fixture(SSOGPGServiceFixture())
163 self.create_user_and_login()
164
165 bad_fingerprint = 'A' * 13
166 resp = self.client.post(
167 reverse('gpg_keys'),
168 dict(action='claim_gpg', fingerprint=bad_fingerprint))
169 msg = make_form_error_message(gpg_messages.bad_fingerprint_format())
170 self.assertContains(resp, msg, html=True)
171
172 @switches(GPG_SERVICE=True)
173 def test_import_existing_key_errors(self):
174 self.use_fixture(SSOGPGServiceFixture())
175 user = self.create_user_and_login()
176 gpg_client = SSOGPGClient()
177 key_id = '4C4834CF'
178 fingerprint = '8C470B2A0B31568E110D432516281F2E007C98D2'
179 keysize = 4096
180 algorithm = 'D'
181 enabled = True
182 can_encrypt = True
183 gpg_client.addKeyForTest(
184 user.get_openid_identity_url(), key_id, fingerprint, keysize,
185 algorithm, enabled, can_encrypt)
186
187 resp = self.client.post(
188 reverse('gpg_keys'),
189 dict(action='claim_gpg', fingerprint=fingerprint))
190 msg = make_form_error_message(
191 gpg_messages.key_already_imported(fingerprint))
192 self.assertContains(resp, msg, html=True)
193
194 @switches(GPG_SERVICE=True)
195 def test_import_missing_key_errors(self):
196 self.use_fixture(SSOGPGServiceFixture())
197 self.create_user_and_login()
198 fingerprint = '8C470B2A0B31568E110D432516281F2E007C98D2'
199 resp = self.client.post(
200 reverse('gpg_keys'),
201 dict(action='claim_gpg', fingerprint=fingerprint))
202 msg = make_form_error_message(gpg_messages.unknown_key())
203 self.assertContains(resp, msg, html=True)
204
205 @switches(GPG_SERVICE=True)
206 def test_import_expired_key_errors(self):
207 self.use_fixture(SSOGPGServiceFixture())
208 self.create_user_and_login()
209 expired_fingerprint = 'ECA5B797586F2E27381A16CFDE6C9167046C6D63'
210 resp = self.client.post(
211 reverse('gpg_keys'),
212 dict(action='claim_gpg', fingerprint=expired_fingerprint))
213 msg = make_form_error_message(
214 gpg_messages.key_expired(expired_fingerprint))
215 self.assertContains(resp, msg, html=True)
216
217 @switches(GPG_SERVICE=True)
218 def test_import_revoked_key_errors(self):
219 self.use_fixture(SSOGPGServiceFixture())
220 self.create_user_and_login()
221 revoked_fingerprint = '84D205F03E1E67096CB54E262BE83793AACCD97C'
222 resp = self.client.post(
223 reverse('gpg_keys'),
224 dict(action='claim_gpg', fingerprint=revoked_fingerprint))
225 msg = make_form_error_message(
226 gpg_messages.key_revoked(revoked_fingerprint))
227 self.assertContains(resp, msg, html=True)
228
229 @switches(GPG_SERVICE=True)
230 def test_claim_encryption_key_workflow(self):
231 self.use_fixture(SSOGPGServiceFixture())
232 user = self.create_user_and_login()
233 encryption_fingerprint = SampleDataKeys.regular_key
234 resp = self.client.post(
235 reverse('gpg_keys'),
236 dict(action='claim_gpg', fingerprint=encryption_fingerprint),
237 follow=True)
238 condensed_fingerprint = encryption_fingerprint.replace(' ', '')
239
240 self.assertEqual(200, resp.status_code)
241 self.assertEqual(1, len(mail.outbox))
242 email = mail.outbox[0]
243 self.assert_email_contains_pgp_block(email)
244
245 pending_tokens = AuthToken.objects.filter(
246 fingerprint=condensed_fingerprint)
247 self.assertEqual(1, len(pending_tokens))
248
249 encrypted_block = extract_encrypted_block(email.body)
250 plain_text = decrypt_content(encrypted_block.encode('ascii'))
251 token_activation_url = extract_url_from_text(plain_text)
252
253 resp = self.client.get(token_activation_url, follow=True)
254 self.assertContains(
255 resp,
256 "<p>Are you sure you want to confirm and validate this "
257 "OpenPGP key?</p>",
258 html=True)
259
260 resp = self.client.post(resp.wsgi_request.get_full_path(), follow=True)
261 msg = make_user_message(
262 gpg_messages.key_added_to_account(condensed_fingerprint))
263 self.assertContains(resp, msg, html=True)
264 keys = SSOGPGClient().getKeysForOwner(
265 user.get_openid_identity_url())['keys']
266 self.assertEqual(1, len(keys))
267 self.assert_fingerprints_are_equal(
268 keys[0]['fingerprint'], encryption_fingerprint)
269
270 @switches(GPG_SERVICE=True)
271 def test_claim_signonly_key_workflow(self):
272 self.use_fixture(SSOGPGServiceFixture())
273 user = self.create_user_and_login()
274 encryption_fingerprint = SampleDataKeys.sign_only_key
275 resp = self.client.post(
276 reverse('gpg_keys'),
277 dict(action='claim_gpg', fingerprint=encryption_fingerprint),
278 follow=True)
279 condensed_fingerprint = encryption_fingerprint.replace(' ', '')
280
281 self.assertEqual(200, resp.status_code)
282 self.assertEqual(1, len(mail.outbox))
283 email = mail.outbox[0]
284 self.assert_email_does_not_contain_pgp_block(email)
285
286 # Sign-only tokens create the validation phrase from the key
287 # fingerprint, name of the person requesting the key, and the
288 # datestamp at which the token was created. That last piece of
289 # information is unknowable within a test, so let's retrieve the
290 # token and change it's creation date:
291 token = AuthToken.get_token(
292 fingerprint=condensed_fingerprint)
293 token.date_created = datetime(2016, 4, 12, 16, 0, 0)
294 token.save()
295
296 token_activation_url = extract_url_from_text(email.body)
297
298 resp = self.client.get(token_activation_url, follow=True)
299 self.assertContains(
300 resp,
301 "<p>Thanks for adding your OpenPGP key to Ubuntu One. So we can "
302 "confirm that the key is yours, we need you to use the key to "
303 "sign some text.",
304 html=True)
305 self.assertContains(
306 resp,
307 '<pre>Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F to '
308 'the Ubuntu One user with the email address '
309 'example_user@example.com. 2016-04-12 16:00:00</pre>',
310 html=True)
311
312 url = resp.wsgi_request.get_full_path()
313 resp = self.client.post(
314 url,
315 dict(clearsigned=CORRECT_TEXT_SIGNED_WITH_CORRECT_KEY),
316 follow=True)
317
318 msg = make_user_message(
319 gpg_messages.key_added_to_account(condensed_fingerprint))
320 self.assertContains(resp, msg, html=True)
321 keys = SSOGPGClient().getKeysForOwner(
322 user.get_openid_identity_url())['keys']
323 self.assertEqual(1, len(keys))
324 self.assert_fingerprints_are_equal(
325 keys[0]['fingerprint'], encryption_fingerprint)
326
327 def create_signonly_authtoken(self, user, fingerprint):
328 return self.factory.make_authtoken(
329 token_type=AuthTokenType.VALIDATESIGNONLYGPG,
330 email=user.preferredemail.email,
331 fingerprint=fingerprint,
332 date_created=datetime(2016, 4, 12, 16, 0, 0),
333 requester=user,
334 )
335
336 def create_signonly_gpg_token_and_sign(self, fingerprint, signed_text):
337 """Create an auth token for a sign-only key, and sign it with 'text'.
338
339 :param fingerprint: The fingerprint of the GPG key you want to
340 register.
341 :param signed_text: THe signed text to use when claiming the gpg key.
342 :returns: The final reponse object after submitting the claim form.
343 """
344 self.use_fixture(SSOGPGServiceFixture())
345 user = self.create_user_and_login()
346 token = self.create_signonly_authtoken(user, fingerprint)
347
348 args = {
349 'fingerprint': fingerprint,
350 'authtoken': token.raw_token,
351 }
352 return self.client.post(
353 reverse('confirm_signonly_gpg_key', kwargs=args),
354 dict(clearsigned=signed_text))
355
356 def test_claiming_signonly_key_fails_without_signed_text(self):
357 resp = self.create_signonly_gpg_token_and_sign(
358 SampleDataKeys.sign_only_key, '')
359 msg = make_form_error_message(gpg_messages.missing_signed_text())
360 self.assertContains(resp, msg, html=True)
361
362 def test_claiming_signonly_key_fails_when_signed_with_different_key(self):
363 resp = self.create_signonly_gpg_token_and_sign(
364 SampleDataKeys.sign_only_key,
365 CORRECT_TEXT_SIGNED_WITH_DIFFERENT_KEY)
366 condensed_key = SampleDataKeys.regular_key.replace(' ', '')
367 msg = make_form_error_message(
368 gpg_messages.signing_key_mismatch(condensed_key))
369 self.assertContains(resp, msg, html=True)
370
371 def test_claiming_signonly_key_fails_when_signed_text_is_modified(self):
372 resp = self.create_signonly_gpg_token_and_sign(
373 SampleDataKeys.sign_only_key,
374 MODIFIED_TEXT_SIGNED_WITH_CORRECT_KEY)
375 msg = make_form_error_message(
376 gpg_messages.validation_phrase_mismatch())
377 self.assertContains(resp, msg, html=True)
378
379 def test_claiming_signonly_key_fails_when_signing_with_unknown_key(self):
380 resp = self.create_signonly_gpg_token_and_sign(
381 SampleDataKeys.sign_only_key, CORRECT_TEXT_SIGNED_WITH_UNKNOWN_KEY)
382 self.assertContains(
383 resp,
384 'Error: Could not find GPG public key.')
385
386
387# The correct text, signed with the correct key:
388CORRECT_TEXT_SIGNED_WITH_CORRECT_KEY = dedent("""\
389 -----BEGIN PGP SIGNED MESSAGE-----
390 Hash: SHA1
391
392 Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F to
393 the Ubuntu One user with the email address
394 example_user@example.com. 2016-04-12 16:00:00
395 -----BEGIN PGP SIGNATURE-----
396 Version: GnuPG v1
397
398 iEYEARECAAYFAlcVoAcACgkQfYiRNxewWo8MXQCfVGKwjchBRvzio8Kgpoz7SuDo
399 zh0An1Fvx5KP1IEt1ODlPujW9YCENnBT
400 =ewCw
401 -----END PGP SIGNATURE-----
402 """)
403
404# Correct text signed with a different key (that we know about)
405CORRECT_TEXT_SIGNED_WITH_DIFFERENT_KEY = dedent("""\
406 -----BEGIN PGP SIGNED MESSAGE-----
407 Hash: SHA1
408
409 Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F
410 to the Ubuntu One user person-2. 2016-04-12 16:00:00
411 -----BEGIN PGP SIGNATURE-----
412 Version: GnuPG v1
413
414 iEYEARECAAYFAlcNxBAACgkQ2yWXVgK6Xva0awCgmer6hs4htHQZwNcwns4UsEV9
415 fh8AoKmMneOkzItHwpju9fDXtP93aAUX
416 =ZIGA
417 -----END PGP SIGNATURE-----
418""")
419
420# signed with the correct key, but the text was changed:
421MODIFIED_TEXT_SIGNED_WITH_CORRECT_KEY = dedent("""\
422 -----BEGIN PGP SIGNED MESSAGE-----
423 Hash: SHA1
424
425 Please do not register 447DBF38C4F9C4ED752246B77D88913717B05A8F
426 to the Ubuntu One user person-2. 2016-04-12 16:00:00
427 -----BEGIN PGP SIGNATURE-----
428 Version: GnuPG v1
429
430 iEYEARECAAYFAlcQGMQACgkQfYiRNxewWo9L6ACgspzv2dOa+HFjxZRXe33cgKyH
431 WTUAnRtrZUIULJ9MA3q+G4whKWZ5DW7R
432 =BCHM
433 -----END PGP SIGNATURE-----
434""")
435
436# The text is correct, but it's signed with an unknown key:
437CORRECT_TEXT_SIGNED_WITH_UNKNOWN_KEY = dedent("""\
438 -----BEGIN PGP SIGNED MESSAGE-----
439 Hash: SHA1
440
441 Please register 447DBF38C4F9C4ED752246B77D88913717B05A8F
442 to the Ubuntu One user person-2. 2016-04-12 16:00:00
443 -----BEGIN PGP SIGNATURE-----
444 Version: GnuPG v1
445
446 iQIcBAEBAgAGBQJXEBtaAAoJELCMjm0FjU8pOwQP/18G/Bfezxa1NMaOl4salaR+
447 xklj5TASP5hEsbE9etoJlaangsyIDS7b5NLnNX1kz9GfK9K6NIjDpBZGidPMPu6P
448 qZ9rnlM1Mi0pxce0a9GJRc65ap3ycMBjeCQBX2cETjkMi5ODGw5R/HZ1Jg0cvM/J
449 h8veZ9trdcNN+uUjbpSgti5Q6FswvrxJ10hAadPJCYkSid3pOjSTFDJ9+YoXbLqQ
450 bFsq3REaVO1I+aDzEgPUcdOYEuzyE0XK51Z8tYxfwgsHJbxpup0EfzQYvnpUzAYg
451 xv5IdDhxhRlT9whcO8TcGvkU3rHch4JIImGe/Tcmf+ftPdemVkiak2O3559g3di+
452 UGtvsW7jgKp6z4cZFoPyR0ihW9QU2DH4bDfQ7kl4PS0X9Xmby5Fcr4Dci+iBz/Z+
453 g4oeiPvAqIAMRMQu5hgEFNIOOeSNxrTawrDPxZAHYt2Fg/ubF5SW9/0qrWd6AqYQ
454 LpCClOQkc7NtL2dXzy37UvDL5ZTrCXicrs4e8JXYtrZTo+chedeDfYk1p1oxiiB8
455 X9/USTEMgngdQOpbm2f4nViTK6zLSecMiKLxvzRhuYGKKMXuaPw+qB6+bs7XA590
456 4BP/x7hKGpBJCaBWd/XQStk557PC29EXJVW8e+6N0c8S0hyAhbxoU5KqKuPZvscZ
457 5Sk98yrCrlj23u0vfIhw
458 =QwsT
459 -----END PGP SIGNATURE-----
460""")
0461
=== modified file 'src/webui/urls.py'
--- src/webui/urls.py 2016-03-10 17:11:43 +0000
+++ src/webui/urls.py 2016-04-28 00:53:41 +0000
@@ -10,7 +10,8 @@
10repls = {10repls = {
11 'token': '(?P<token>[A-Za-z0-9]{16})',11 'token': '(?P<token>[A-Za-z0-9]{16})',
12 'authtoken': '(?P<authtoken>%s)' % AUTHTOKEN_PATTERN,12 'authtoken': '(?P<authtoken>%s)' % AUTHTOKEN_PATTERN,
13 'email_address': '(?P<email_address>.+)'13 'email_address': '(?P<email_address>.+)',
14 'fingerprint': '(?P<fingerprint>.+)',
14}15}
1516
16urlpatterns = patterns(17urlpatterns = patterns(
@@ -51,6 +52,10 @@
51 'confirm_email', name='confirm_email'),52 'confirm_email', name='confirm_email'),
52 url(r'^%(token)s/token/%(authtoken)s/\+newemail/%(email_address)s$' %53 url(r'^%(token)s/token/%(authtoken)s/\+newemail/%(email_address)s$' %
53 repls, 'confirm_email', name='confirm_email'),54 repls, 'confirm_email', name='confirm_email'),
55 url(r'^token/%(authtoken)s/\+newgpgkey/%(fingerprint)s$' % repls,
56 'confirm_gpg_key', name='confirm_gpg_key'),
57 url(r'^token/%(authtoken)s/\+newsignonlygpgkey/%(fingerprint)s$' % repls,
58 'confirm_signonly_gpg_key', name='confirm_signonly_gpg_key'),
54 url(r'^\+bad-token', 'bad_token', name='bad_token'),59 url(r'^\+bad-token', 'bad_token', name='bad_token'),
55 url(r'^\+logout-to-confirm', 'logout_to_confirm',60 url(r'^\+logout-to-confirm', 'logout_to_confirm',
56 name='logout_to_confirm'),61 name='logout_to_confirm'),
@@ -126,6 +131,7 @@
126 name='account_deactivate'),131 name='account_deactivate'),
127 url(r'^\+delete$', 'delete_account', name='delete_account'),132 url(r'^\+delete$', 'delete_account', name='delete_account'),
128 url(r'^activity$', 'auth_log', name='auth_log'),133 url(r'^activity$', 'auth_log', name='auth_log'),
134 url(r'^gpg-keys$', 'gpg_keys', name='gpg_keys'),
129)135)
130# TODO: Support the login-tokens, if it makes sense.136# TODO: Support the login-tokens, if it makes sense.
131urlpatterns += patterns(137urlpatterns += patterns(
132138
=== added file 'src/webui/utils.py'
--- src/webui/utils.py 1970-01-01 00:00:00 +0000
+++ src/webui/utils.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,10 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).
4from django.utils import six
5from django.utils.functional import lazy
6from django.utils.safestring import mark_safe
7
8# https://docs.djangoproject.com/en/1.7/topics/i18n/translation/
9# other-uses-of-lazy-in-delayed-translations
10mark_safe_lazy = lazy(mark_safe, six.text_type)
011
=== modified file 'src/webui/views/account.py'
--- src/webui/views/account.py 2016-02-17 22:52:46 +0000
+++ src/webui/views/account.py 2016-04-28 00:53:41 +0000
@@ -17,7 +17,9 @@
17from django.template import RequestContext17from django.template import RequestContext
18from django.template.loader import render_to_string18from django.template.loader import render_to_string
19from django.template.response import TemplateResponse19from django.template.response import TemplateResponse
20from django.utils.timezone import now
20from django.views.decorators.vary import vary_on_headers21from django.views.decorators.vary import vary_on_headers
22from gargoyle.decorators import switch_is_active
2123
22from openid.yadis.constants import YADIS_HEADER_NAME, YADIS_CONTENT_TYPE24from openid.yadis.constants import YADIS_HEADER_NAME, YADIS_CONTENT_TYPE
2325
@@ -26,6 +28,7 @@
26 send_invalidation_email_notice,28 send_invalidation_email_notice,
27 send_notification_to_invalidated_email_address,29 send_notification_to_invalidated_email_address,
28 send_validation_email_request,30 send_validation_email_request,
31 send_gpg_validation_message,
29)32)
30from identityprovider.forms import (33from identityprovider.forms import (
31 DeleteAccountForm,34 DeleteAccountForm,
@@ -59,6 +62,8 @@
5962
60from webui.constants import EMAIL_EXISTS_ERROR, VERIFY_EMAIL_MESSAGE63from webui.constants import EMAIL_EXISTS_ERROR, VERIFY_EMAIL_MESSAGE
61from webui.decorators import check_readonly, sso_login_required64from webui.decorators import check_readonly, sso_login_required
65from webui.forms import ClaimOpenPGPKeyForm
66from webui.gpg import SSOGPGClient
62from webui.views.const import (67from webui.views.const import (
63 DETAILS_UPDATED,68 DETAILS_UPDATED,
64 EMAIL_DELETED,69 EMAIL_DELETED,
@@ -464,3 +469,55 @@
464 'auth_log': auth_log_items,469 'auth_log': auth_log_items,
465 'full_auth_log_length': full_auth_log_length})470 'full_auth_log_length': full_auth_log_length})
466 return render_to_response('account/auth_log.html', context)471 return render_to_response('account/auth_log.html', context)
472
473
474@switch_is_active('GPG_SERVICE')
475@sso_login_required
476@check_readonly
477def gpg_keys(request):
478 client = SSOGPGClient()
479 template_vars = dict(current_section='gpg_keys')
480 if request.method == 'POST':
481 action = request.POST['action']
482 form = ClaimOpenPGPKeyForm(request.POST)
483 if action == 'claim_gpg':
484 if form.is_valid():
485 key_details = form.cleaned_data['key_details']
486 if key_details['can_encrypt']:
487 token_type = AuthTokenType.VALIDATEGPG
488 else:
489 token_type = AuthTokenType.VALIDATESIGNONLYGPG
490 token = AuthToken.objects.create(
491 token_type=token_type,
492 email=request.user.preferredemail.email,
493 fingerprint=form.cleaned_data['fingerprint'],
494 date_created=now(),
495 requester=request.user,
496 )
497 send_gpg_validation_message(
498 settings.SSO_ROOT_URL, key_details, token,
499 request.user, client)
500 return HttpResponseRedirect(reverse('gpg_keys'))
501 else:
502 form = ClaimOpenPGPKeyForm()
503 template_vars['claim_form'] = form
504 user_keys = client.getKeysForOwner(
505 request.user.get_openid_identity_url())['keys']
506 template_vars['pending_keys'] = AuthToken.objects.filter(
507 requester=request.user,
508 token_type__in=[
509 AuthTokenType.VALIDATEGPG,
510 AuthTokenType.VALIDATESIGNONLYGPG,
511 ]
512 )
513 template_vars['enabled_keys'] = []
514 template_vars['disabled_keys'] = []
515 for key in user_keys:
516 if key['enabled']:
517 template_vars['enabled_keys'].append(key)
518 else:
519 template_vars['disabled_keys'].append(key)
520 template_vars['preferred_email'] = request.user.preferredemail
521
522 context = RequestContext(request, template_vars)
523 return render_to_response('account/gpg_keys.html', context)
467524
=== added file 'src/webui/views/gpg_messages.py'
--- src/webui/views/gpg_messages.py 1970-01-01 00:00:00 +0000
+++ src/webui/views/gpg_messages.py 2016-04-28 00:53:41 +0000
@@ -0,0 +1,100 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).
4
5"""Build GPG-related error messages for user consumption."""
6
7from django.utils.html import (
8 escape,
9 format_html,
10)
11from django.utils.translation import ugettext_lazy as _
12
13from webui.utils import mark_safe_lazy
14
15
16def bad_fingerprint_format():
17 intro = _("""There seems to be a problem with the fingerprint you
18 submitted. You can get your gpg fingerprint by opening a
19 terminal and typing:
20 {instructions}
21 Please try again.
22 """)
23 html = """
24 <blockquote>
25 <kbd>gpg --fingerprint</kbd>
26 </blockquote>"""
27 return mark_safe_lazy(intro.format(instructions=html))
28
29
30def key_already_imported(fingerprint):
31 return mark_safe_lazy(
32 _('The key {fingerprint} has already been imported.').format(
33 fingerprint='<code>%s</code>' % escape(fingerprint)))
34
35
36def unknown_key():
37 intro = _("Ubuntu One could not import your OpenPGP key")
38 question = _("Did you enter your complete fingerprint correctly?")
39 fingerprint_help = _("Help with fingerprints")
40 body_text = _(
41 "Is your key in the Ubuntu keyserver yet? You may have to wait"
42 "between ten minutes (if you pushed directly to the Ubuntu key"
43 "server) and one hour (if you pushed your key to another server)."
44 )
45 publishing_help = _("Help with publishing keys")
46
47 return format_html(
48 """
49 <strong>{intro}</strong>
50 <ul>
51 <li>{question}
52 (<a href="http://launchpad.net/+help-registry/import-pgp-key.html">
53 {fingerprint_help}</a>)</li>
54
55 <li>{body_text}
56 (<a href="http://launchpad.net/+help-registry/openpgp-keys.html"
57 >{publishing_help}</a>)
58 </li>
59 </ul>
60 """,
61 intro=intro,
62 question=question,
63 fingerprint_help=fingerprint_help,
64 body_text=body_text,
65 publishing_help=publishing_help,
66 )
67
68
69def key_expired(fingerprint):
70 return mark_safe_lazy(
71 _('The key {fingerprint} has expired, and cannot be used.').format(
72 fingerprint='<code>%s</code>' % escape(fingerprint)))
73
74
75def key_revoked(fingerprint):
76 return mark_safe_lazy(
77 _('The key {fingerprint} has been revoked, '
78 'and cannot be used.').format(
79 fingerprint='<code>%s</code>' % escape(fingerprint)))
80
81
82def signing_key_mismatch(fingerprint):
83 return _(
84 'The key used to sign the content ({fingerprint}) is not the '
85 'key you were registering').format(fingerprint=fingerprint)
86
87
88def validation_phrase_mismatch():
89 return _('Error: The signed text does not match the '
90 'validation phrase.')
91
92
93def key_added_to_account(fingerprint):
94 return _(
95 'The GPG Key {fingerprint} has been added to your account').format(
96 fingerprint=fingerprint)
97
98
99def missing_signed_text():
100 return _("Error: Missing signed text.")
0101
=== modified file 'src/webui/views/registration.py'
--- src/webui/views/registration.py 2016-04-26 18:08:15 +0000
+++ src/webui/views/registration.py 2016-04-28 00:53:41 +0000
@@ -117,7 +117,6 @@
117 data['creation_source'] = WEB_CREATION_SOURCE117 data['creation_source'] = WEB_CREATION_SOURCE
118118
119 url = redirection_url_for_token(token)119 url = redirection_url_for_token(token)
120
121 try:120 try:
122 api = get_api_client(request)121 api = get_api_client(request)
123 api.register(**data)122 api.register(**data)
124123
=== modified file 'src/webui/views/ui.py'
--- src/webui/views/ui.py 2016-03-16 13:04:24 +0000
+++ src/webui/views/ui.py 2016-04-28 00:53:41 +0000
@@ -82,8 +82,13 @@
82 requires_cookies,82 requires_cookies,
83 require_twofactor_enabled,83 require_twofactor_enabled,
84)84)
85from webui.views.utils import add_captcha_settings85from webui.forms import VerifySignedTextForm
86from webui.views import registration86from webui.gpg import SSOGPGClient
87from webui.views.utils import add_captcha_settings, require_twofactor
88from webui.views import (
89 gpg_messages,
90 registration,
91 )
8792
8893
89ACCOUNT_CREATED = _("Your account was created successfully")94ACCOUNT_CREATED = _("Your account was created successfully")
@@ -433,35 +438,42 @@
433def claim_token(request, authtoken):438def claim_token(request, authtoken):
434 try:439 try:
435 token = AuthToken.get_token(raw_token=authtoken)440 token = AuthToken.get_token(raw_token=authtoken)
436 return _handle_confirmation(confirmation_type=token.token_type,441 return _handle_confirmation(confirmation_code=authtoken,
437 confirmation_code=authtoken,442 token=token)
438 email=token.email)
439 except AuthToken.DoesNotExist:443 except AuthToken.DoesNotExist:
440 raise Http404()444 raise Http404()
441445
442446
443def _handle_confirmation(447def _handle_confirmation(confirmation_code, token):
444 confirmation_type, confirmation_code, email, token=None):448 """Handle a confirmation for an action based on 'token'.
445 """Handle a confirmation for an action requested on 'email'.
446449
447 Either finish the work in this view, or redirect to a view that can. Also450 Either finish the work in this view, or redirect to a view that can. Also
448 accepts an optional OpenID-transaction token.451 accepts an optional OpenID-transaction token.
449452
450 """453 """
451454 confirmation_type = token.token_type
455 # The 'authtoken' argument is used by all validation views:
452 args = {456 args = {
453 'authtoken': confirmation_code,457 'authtoken': confirmation_code,
454 'email_address': email
455 }458 }
456 if token is not None:
457 args['token'] = token
458459
460 # If the view we are directing to requires arguments other than 'authtoken'
461 # they should be extracted below:
459 if confirmation_type == AuthTokenType.PASSWORDRECOVERY:462 if confirmation_type == AuthTokenType.PASSWORDRECOVERY:
460 view = 'reset_password'463 view = 'reset_password'
464 args['email_address'] = token.email
461 elif confirmation_type == AuthTokenType.VALIDATEEMAIL:465 elif confirmation_type == AuthTokenType.VALIDATEEMAIL:
462 view = 'confirm_email'466 view = 'confirm_email'
467 args['email_address'] = token.email
463 elif confirmation_type == AuthTokenType.INVALIDATEEMAIL:468 elif confirmation_type == AuthTokenType.INVALIDATEEMAIL:
464 view = 'invalidate_email'469 view = 'invalidate_email'
470 args['email_address'] = token.email
471 elif confirmation_type == AuthTokenType.VALIDATEGPG:
472 view = 'confirm_gpg_key'
473 args['fingerprint'] = token.fingerprint
474 elif confirmation_type == AuthTokenType.VALIDATESIGNONLYGPG:
475 view = 'confirm_signonly_gpg_key'
476 args['fingerprint'] = token.fingerprint
465 else:477 else:
466 msg = ('Unknown type %s for confirmation code "%s"' %478 msg = ('Unknown type %s for confirmation code "%s"' %
467 (confirmation_type, confirmation_code))479 (confirmation_type, confirmation_code))
@@ -501,16 +513,11 @@
501 'captcha_required': True}))513 'captcha_required': True}))
502514
503515
516@require_twofactor
504def confirm_email(request, authtoken, email_address, token=None):517def confirm_email(request, authtoken, email_address, token=None):
505 captcha_error_message = ''518 captcha_error_message = ''
506 captcha_required = gargoyle.is_active('CAPTCHA', request)519 captcha_required = gargoyle.is_active('CAPTCHA', request)
507520
508 if not twofactor.is_authenticated(request):
509 messages.warning(request,
510 _('Please log in to use this confirmation code'))
511 next_url = urlquote(request.get_full_path())
512 return HttpResponseRedirect('%s?next=%s' % (settings.LOGIN_URL,
513 next_url))
514 atrequest = verify_token_string(authtoken, email_address)521 atrequest = verify_token_string(authtoken, email_address)
515 if (atrequest is None or522 if (atrequest is None or
516 atrequest.token_type != AuthTokenType.VALIDATEEMAIL):523 atrequest.token_type != AuthTokenType.VALIDATEEMAIL):
@@ -572,6 +579,96 @@
572 return render(request, 'account/confirm_new_email.html', context)579 return render(request, 'account/confirm_new_email.html', context)
573580
574581
582@require_twofactor
583def confirm_gpg_key(request, authtoken, fingerprint):
584 atrequest = AuthToken.get_token(
585 fingerprint=fingerprint,
586 raw_token=authtoken,
587 token_type=AuthTokenType.VALIDATEGPG,
588 requester=request.user)
589 if atrequest is None:
590 return HttpResponseRedirect('/+bad-token')
591
592 email = get_object_or_404(EmailAddress, email__iexact=atrequest.email)
593 if request.user.id != email.account.id:
594 # The user is authenticated to a different account.
595 # Potentially, the token was leaked or intercepted. Let's
596 # delete it just in case. The real user can generate another
597 # token easily enough.
598 atrequest.delete()
599 # return a 404 response instead of raising so that
600 # the TransactionMiddleware will not rollback the dirty transaction
601 return HttpResponseNotFound()
602
603 if request.method == 'POST':
604 gpg_client = SSOGPGClient()
605 # only confirm the email address if the form was submitted
606 gpg_client.addKeyForOwner(
607 request.user.get_openid_identity_url(),
608 fingerprint
609 )
610 atrequest.consume()
611 messages.success(
612 request, gpg_messages.key_added_to_account(fingerprint))
613 return HttpResponseRedirect(
614 atrequest.redirection_url or reverse('gpg_keys')
615 )
616
617 # form was not submitted
618 context = {
619 'fingerprint': fingerprint,
620 }
621 return render(request, 'account/confirm_new_gpg.html', context)
622
623
624@require_twofactor
625def confirm_signonly_gpg_key(request, authtoken, fingerprint):
626 atrequest = AuthToken.get_token(
627 fingerprint=fingerprint,
628 raw_token=authtoken,
629 token_type=AuthTokenType.VALIDATESIGNONLYGPG,
630 requester=request.user)
631 # atrequest = verify_token_string(authtoken, email_address)
632 if atrequest is None:
633 return HttpResponseRedirect('/+bad-token')
634
635 email = get_object_or_404(EmailAddress, email__iexact=atrequest.email)
636 if request.user.id != email.account.id:
637 # The user is authenticated to a different account.
638 # Potentially, the token was leaked or intercepted. Let's
639 # delete it just in case. The real user can generate another
640 # token easily enough.
641 atrequest.delete()
642 # return a 404 response instead of raising so that
643 # the TransactionMiddleware will not rollback the dirty transaction
644 return HttpResponseNotFound()
645
646 if request.method == 'POST':
647 form = VerifySignedTextForm(request.POST, atrequest)
648 if form.is_valid():
649 gpg_client = SSOGPGClient()
650 gpg_client.addKeyForOwner(
651 request.user.get_openid_identity_url(),
652 fingerprint
653 )
654 atrequest.consume()
655 messages.success(
656 request, gpg_messages.key_added_to_account(fingerprint))
657 return HttpResponseRedirect(
658 atrequest.redirection_url or reverse('gpg_keys')
659 )
660 else:
661 form = VerifySignedTextForm()
662
663 # form was not submitted
664 context = {
665 'fingerprint': fingerprint,
666 'validation_phrase': atrequest.validation_phrase,
667 'form': form,
668 }
669 return render(request, 'account/confirm_new_signonly_gpg.html', context)
670
671
575def bad_token(request):672def bad_token(request):
576 return TemplateResponse(request, 'bad_token.html')673 return TemplateResponse(request, 'bad_token.html')
577674
578675
=== modified file 'src/webui/views/utils.py'
--- src/webui/views/utils.py 2015-05-08 19:25:27 +0000
+++ src/webui/views/utils.py 2016-04-28 00:53:41 +0000
@@ -7,8 +7,13 @@
7from functools import wraps7from functools import wraps
88
9from django.conf import settings9from django.conf import settings
10from django.contrib import messages
10from django.http import HttpResponseNotAllowed, HttpResponseRedirect11from django.http import HttpResponseNotAllowed, HttpResponseRedirect
11from django.template.response import TemplateResponse12from django.template.response import TemplateResponse
13from django.utils.http import urlquote
14from django.utils.translation import ugettext_lazy as _
15
16from identityprovider.models import twofactor
1217
1318
14class HttpResponseSeeOther(HttpResponseRedirect):19class HttpResponseSeeOther(HttpResponseRedirect):
@@ -50,3 +55,43 @@
50 'CAPTCHA_API_URL_SECURE': settings.CAPTCHA_API_URL_SECURE}55 'CAPTCHA_API_URL_SECURE': settings.CAPTCHA_API_URL_SECURE}
51 d.update(context)56 d.update(context)
52 return d57 return d
58
59
60def require_twofactor(view_fn):
61 """A view decorator that requires that the user be authenticated with 2fa.
62
63 For a given view function like this::
64
65 def my_view(request, some, other, optional, args):
66 ...
67
68 You can use this decorator like so::
69
70 @require_twofactor
71 def my_view(request, some, other, optional, args):
72 ...
73
74 This is exactly equivilent to writing the following::
75
76 def my_view(request, some, other, optional, args):
77 if not twofactor.is_authenticated(request):
78 messages.warning(
79 request,
80 _('Please log in to use this confirmation code'))
81 next_url = urlquote(request.get_full_path())
82 return HttpResponseRedirect(
83 '%s?next=%s' % (settings.LOGIN_URL, next_url))
84
85 Since this is a pattern used in several places, it's worth abstracting into
86 a re-usable decorator.
87 """
88 @wraps(view_fn)
89 def wrapper(request, *args, **kwargs):
90 if not twofactor.is_authenticated(request):
91 messages.warning(request,
92 _('Please log in to use this confirmation code'))
93 next_url = urlquote(request.get_full_path())
94 return HttpResponseRedirect('%s?next=%s' % (settings.LOGIN_URL,
95 next_url))
96 return view_fn(request, *args, **kwargs)
97 return wrapper