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