david / django-oauth (http://oauth.net/)
Support of OAuth in Django. Note that http://code.welldev.org/django-oauth-plus will use python-oauth2 if you're interested in it.
| commit 30: | 95a6aab951cd |
| parent 29: | 2710c95e00c6 |
| branch: | default |
OAuth 1.0a implementation, should be compatible with the (deprecated/unsecure) version 1.0 too. Please let me know if that's not the case.
12 months ago
Changed (Δ12.9 KB):
raw changeset »
oauth_provider/models.py (1 lines added, 0 lines removed)
oauth_provider/stores.py (24 lines added, 8 lines removed)
oauth_provider/tests.py (359 lines added, 4 lines removed)
oauth_provider/views.py (5 lines added, 0 lines removed)
Up to file-list oauth_provider/models.py:
| … | … | @@ -96,6 +96,7 @@ class Token(models.Model): |
96 |
96 |
|
97 |
97 |
if only_key: |
98 |
98 |
del token_dict['oauth_token_secret'] |
99 |
del token_dict['oauth_callback_confirmed'] |
|
99 |
100 |
|
100 |
101 |
return urllib.urlencode(token_dict) |
101 |
102 |
Up to file-list oauth_provider/stores.py:
1 |
1 |
from oauth.oauth import OAuthDataStore, OAuthError, escape |
2 |
2 |
|
3 |
from models import Nonce, Token, Consumer, Resource |
|
3 |
from models import Nonce, Token, Consumer, Resource, generate_random |
|
4 |
from consts import VERIFIER_SIZE |
|
4 |
5 |
|
5 |
6 |
|
6 |
7 |
class DataStore(OAuthDataStore): |
| … | … | @@ -50,6 +51,10 @@ class DataStore(OAuthDataStore): |
50 |
51 |
token_type=Token.REQUEST, |
51 |
52 |
timestamp=self.timestamp, |
52 |
53 |
resource=resource) |
54 |
# OAuth 1.0a: if there is a callback, set it |
|
55 |
if oauth_callback: |
|
56 |
self.request_token.set_callback(oauth_callback) |
|
57 |
||
53 |
58 |
return self.request_token |
54 |
59 |
raise OAuthError('Consumer key does not match.') |
55 |
60 |
|
| … | … | @@ -57,18 +62,29 @@ class DataStore(OAuthDataStore): |
57 |
62 |
if oauth_consumer.key == self.consumer.key \ |
58 |
63 |
and oauth_token.key == self.request_token.key \ |
59 |
64 |
and self.request_token.is_approved: |
60 |
self.access_token = Token.objects.create_token(consumer=self.consumer, |
|
61 |
token_type=Token.ACCESS, |
|
62 |
timestamp=self.timestamp, |
|
63 |
user=self.request_token.user, |
|
64 |
resource=self.request_token.resource) |
|
65 |
return self.access_token |
|
66 |
|
|
65 |
# OAuth 1.0a: if there is a callback confirmed, check the verifier |
|
66 |
if (self.request_token.callback_confirmed \ |
|
67 |
and oauth_verifier == self.request_token.verifier) \ |
|
68 |
or not self.request_token.callback_confirmed: |
|
69 |
self.access_token = Token.objects.create_token(consumer=self.consumer, |
|
70 |
token_type=Token.ACCESS, |
|
71 |
timestamp=self.timestamp, |
|
72 |
user=self.request_token.user, |
|
73 |
resource=self.request_token.resource) |
|
74 |
return self.access_token |
|
75 |
raise OAuthError('Consumer key or token key does not match. ' \ |
|
76 |
+'Make sure your request token is approved. ' \ |
|
77 |
+'Check your verifier too if you use OAuth 1.0a.') |
|
67 |
78 |
|
68 |
79 |
def authorize_request_token(self, oauth_token, user): |
69 |
80 |
if oauth_token.key == self.request_token.key: |
70 |
81 |
# authorize the request token in the store |
71 |
82 |
self.request_token.is_approved = True |
83 |
||
84 |
# OAuth 1.0a: if there is a callback confirmed, we must set a verifier |
|
85 |
if self.request_token.callback_confirmed: |
|
86 |
self.request_token.verifier = generate_random(VERIFIER_SIZE) |
|
87 |
||
72 |
88 |
self.request_token.user = user |
73 |
89 |
self.request_token.save() |
74 |
90 |
return self.request_token |
Up to file-list oauth_provider/tests.py:
| … | … | @@ -9,7 +9,7 @@ requiring Users to disclose their Servic |
9 |
9 |
Consumers. More generally, OAuth creates a freely-implementable and generic |
10 |
10 |
methodology for API authentication. |
11 |
11 |
|
12 |
.. _`OAuth protocol`: http://oauth.net/core/1.0 |
|
12 |
.. _`OAuth protocol`: http://oauth.net/core/1.0a |
|
13 |
13 |
|
14 |
14 |
.. warning:: |
15 |
15 |
At this early stage of the development, feedback is really appreciated. |
| … | … | @@ -123,8 +123,11 @@ can run tests from this example with thi |
123 |
123 |
... |
124 |
124 |
|
125 |
125 |
|
126 |
Protocol Example |
|
127 |
================ |
|
126 |
Protocol Example 1.0 |
|
127 |
==================== |
|
128 |
||
129 |
DUE TO THE SECURITY ISSUE, THIS EXAMPLE IS NOT THE RECOMMENDED WAY ANYMORE. |
|
130 |
SEE BELOW FOR A MORE ROBUST EXAMPLE WHICH IS 1.0a COMPLIANT. |
|
128 |
131 |
|
129 |
132 |
In this example, the Service Provider photos.example.net is a photo sharing |
130 |
133 |
website, and the Consumer printer.example.com is a photo printing website. |
| … | … | @@ -381,7 +384,7 @@ approved:: |
381 |
384 |
>>> response.status_code |
382 |
385 |
401 |
383 |
386 |
>>> response.content |
384 |
'Consumer key or token key does not match. Make sure your request token is approved |
|
387 |
'Consumer key or token key does not match. Make sure your request token is approved. Check your verifier too if you use OAuth 1.0a.' |
|
385 |
388 |
|
386 |
389 |
|
387 |
390 |
Accessing Protected Resources |
| … | … | @@ -465,4 +468,356 @@ be able to access the Protected Resource |
465 |
468 |
401 |
466 |
469 |
>>> response.content |
467 |
470 |
'Invalid access token: ...' |
471 |
||
472 |
||
473 |
Clean up |
|
474 |
-------- |
|
475 |
||
476 |
Remove created models' instances to be able to launch 1.0a tests just below:: |
|
477 |
||
478 |
>>> Token.objects.all().delete() |
|
479 |
>>> Resource.objects.all().delete() |
|
480 |
>>> Consumer.objects.all().delete() |
|
481 |
>>> Nonce.objects.all().delete() |
|
482 |
>>> User.objects.all().delete() |
|
483 |
||
484 |
||
485 |
||
486 |
||
487 |
||
488 |
||
489 |
||
490 |
Protocol Example 1.0a |
|
491 |
===================== |
|
492 |
||
493 |
THIS IS THE RECOMMENDED WAY TO USE THIS APPLICATION. |
|
494 |
||
495 |
This example is exactly the same as 1.0 except it uses newly introduced |
|
496 |
arguments to be 1.0a compatible and fix the security issue. |
|
497 |
||
498 |
An account for Jane is necessary:: |
|
499 |
||
500 |
>>> from django.contrib.auth.models import User |
|
501 |
>>> jane = User.objects.create_user('jane', 'jane@example.com', 'toto') |
|
502 |
||
503 |
||
504 |
Documentation and Registration |
|
505 |
------------------------------ |
|
506 |
||
507 |
The Service Provider documentation explains how to register for a Consumer Key |
|
508 |
and Consumer Secret, and declares the following URLs: |
|
509 |
||
510 |
* Request Token URL: |
|
511 |
http://photos.example.net/request_token, using HTTP POST |
|
512 |
* User Authorization URL: |
|
513 |
http://photos.example.net/authorize, using HTTP GET |
|
514 |
* Access Token URL: |
|
515 |
http://photos.example.net/access_token, using HTTP POST |
|
516 |
* Photo (Protected Resource) URL: |
|
517 |
http://photos.example.net/photo with required parameter file and |
|
518 |
optional parameter size |
|
519 |
||
520 |
The Service Provider declares support for the HMAC-SHA1 signature method for |
|
521 |
all requests, and PLAINTEXT only for secure (HTTPS) requests. |
|
522 |
||
523 |
The Consumer printer.example.com already established a Consumer Key and |
|
524 |
Consumer Secret with photos.example.net and advertizes its printing services |
|
525 |
for photos stored on photos.example.net. The Consumer registration is: |
|
526 |
||
527 |
* Consumer Key: dpf43f3p2l4k3l03 |
|
528 |
* Consumer Secret: kd94hf93k423kf44 |
|
529 |
||
530 |
We need to create the Protected Resource and the Consumer first:: |
|
531 |
||
532 |
>>> from oauth_provider.models import Resource, Consumer |
|
533 |
>>> resource = Resource(name='photos', url='/oauth/photo/') |
|
534 |
>>> resource.save() |
|
535 |
>>> CONSUMER_KEY = 'dpf43f3p2l4k3l03' |
|
536 |
>>> CONSUMER_SECRET = 'kd94hf93k423kf44' |
|
537 |
>>> consumer = Consumer(key=CONSUMER_KEY, secret=CONSUMER_SECRET, |
|
538 |
... name='printer.example.com') |
|
539 |
>>> consumer.save() |
|
540 |
||
541 |
||
542 |
Obtaining a Request Token |
|
543 |
------------------------- |
|
544 |
||
545 |
After Jane informs printer.example.com that she would like to print her |
|
546 |
vacation photo stored at photos.example.net, the printer website tries to |
|
547 |
access the photo and receives HTTP 401 Unauthorized indicating it is private. |
|
548 |
The Service Provider includes the following header with the response:: |
|
549 |
||
550 |
>>> from django.test.client import Client |
|
551 |
>>> c = Client() |
|
552 |
>>> response = c.get("/oauth/request_token/") |
|
553 |
>>> response.status_code |
|
554 |
401 |
|
555 |
>>> # depends on REALM_KEY_NAME Django setting |
|
556 |
>>> response._headers['www-authenticate'] |
|
557 |
('WWW-Authenticate', 'OAuth realm=""') |
|
558 |
>>> response.content |
|
559 |
'Invalid request parameters.' |
|
560 |
||
561 |
The Consumer sends the following HTTP POST request to the Service Provider:: |
|
562 |
||
563 |
>>> import time |
|
564 |
>>> parameters = { |
|
565 |
... 'oauth_consumer_key': CONSUMER_KEY, |
|
566 |
... 'oauth_signature_method': 'PLAINTEXT', |
|
567 |
... 'oauth_signature': '%s&' % CONSUMER_SECRET, |
|
568 |
... 'oauth_timestamp': str(int(time.time())), |
|
569 |
... 'oauth_nonce': 'requestnonce', |
|
570 |
... 'oauth_version': '1.0', |
|
571 |
... 'oauth_callback': 'http://printer.example.com/request_token_ready', |
|
572 |
... 'scope': 'photos', # custom argument to specify Protected Resource |
|
573 |
... } |
|
574 |
>>> response = c.get("/oauth/request_token/", parameters) |
|
575 |
||
576 |
The Service Provider checks the signature and replies with an unauthorized |
|
577 |
Request Token in the body of the HTTP response:: |
|
578 |
||
579 |
>>> response.status_code |
|
580 |
200 |
|
581 |
>>> response.content |
|
582 |
'oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true' |
|
583 |
>>> from oauth_provider.models import Token |
|
584 |
>>> token = list(Token.objects.all())[-1] |
|
585 |
>>> token.key in response.content, token.secret in response.content |
|
586 |
(True, True) |
|
587 |
>>> token.callback, token.callback_confirmed |
|
588 |
(u'http://printer.example.com/request_token_ready', True) |
|
589 |
||
590 |
If you try to access a resource with a wrong scope, it will return an error:: |
|
591 |
||
592 |
>>> parameters['scope'] = 'videos' |
|
593 |
>>> response = c.get("/oauth/request_token/", parameters) |
|
594 |
>>> response.status_code |
|
595 |
401 |
|
596 |
>>> response.content |
|
597 |
'Resource videos does not exist.' |
|
598 |
||
599 |
||
600 |
Requesting User Authorization |
|
601 |
----------------------------- |
|
602 |
||
603 |
The Consumer redirects Jane's browser to the Service Provider User |
|
604 |
Authorization URL to obtain Jane's approval for accessing her private photos. |
|
605 |
||
606 |
The Service Provider asks Jane to sign-in using her username and password:: |
|
607 |
||
608 |
>>> parameters = { |
|
609 |
... 'oauth_token': token.key, |
|
610 |
... } |
|
611 |
>>> response = c.get("/oauth/authorize/", parameters) |
|
612 |
>>> response.status_code |
|
613 |
302 |
|
614 |
>>> response['Location'] |
|
615 |
'http://.../accounts/login/?next=/oauth/authorize/%3Foauth_token%3D...' |
|
616 |
>>> token.key in response['Location'] |
|
617 |
True |
|
618 |
||
619 |
If successful, asks her if she approves granting printer.example.com access to |
|
620 |
her private photos. If Jane approves the request, the Service Provider |
|
621 |
redirects her back to the Consumer's callback URL:: |
|
622 |
||
623 |
>>> c.login(username='jane', password='toto') |
|
624 |
True |
|
625 |
>>> token.is_approved |
|
626 |
0 |
|
627 |
>>> response = c.get("/oauth/authorize/", parameters) |
|
628 |
>>> response.status_code |
|
629 |
200 |
|
630 |
>>> response.content |
|
631 |
'Fake authorize view for printer.example.com.' |
|
632 |
||
633 |
>>> # fake authorization by the user |
|
634 |
>>> parameters['authorize_access'] = 1 |
|
635 |
>>> response = c.post("/oauth/authorize/", parameters) |
|
636 |
>>> response.status_code |
|
637 |
302 |
|
638 |
>>> response['Location'] |
|
639 |
'http://printer.example.com/request_token_ready?oauth_verifier=...&oauth_token=...' |
|
640 |
>>> token = list(Token.objects.all())[-1] |
|
641 |
>>> token.key in response['Location'] |
|
642 |
True |
|
643 |
>>> token.is_approved |
|
644 |
1 |
|
645 |
||
646 |
>>> # without session parameter (previous POST removed it) |
|
647 |
>>> response = c.post("/oauth/authorize/", parameters) |
|
648 |
>>> response.status_code |
|
649 |
401 |
|
650 |
>>> response.content |
|
651 |
'Action not allowed.' |
|
652 |
||
653 |
>>> # fake access not granted by the user (set session parameter again) |
|
654 |
>>> response = c.get("/oauth/authorize/", parameters) |
|
655 |
>>> parameters['authorize_access'] = 0 |
|
656 |
>>> response = c.post("/oauth/authorize/", parameters) |
|
657 |
>>> response.status_code |
|
658 |
302 |
|
659 |
>>> response['Location'] |
|
660 |
'http://printer.example.com/request_token_ready?error=Access%20not%20granted%20by%20user.' |
|
661 |
>>> c.logout() |
|
662 |
||
663 |
With OAuth 1.0a, the callback is required, hence the ``OAUTH_CALLBACK_VIEW`` |
|
664 |
setting is useless. |
|
665 |
||
666 |
||
667 |
Obtaining an Access Token |
|
668 |
------------------------- |
|
669 |
||
670 |
Now that the Consumer knows Jane approved the Request Token, it asks the |
|
671 |
Service Provider to exchange it for an Access Token:: |
|
672 |
||
673 |
>>> c = Client() |
|
674 |
>>> parameters = { |
|
675 |
... 'oauth_consumer_key': CONSUMER_KEY, |
|
676 |
... 'oauth_token': token.key, |
|
677 |
... 'oauth_signature_method': 'PLAINTEXT', |
|
678 |
... 'oauth_signature': '%s&%s' % (CONSUMER_SECRET, token.secret), |
|
679 |
... 'oauth_timestamp': str(int(time.time())), |
|
680 |
... 'oauth_nonce': 'accessnonce', |
|
681 |
... 'oauth_version': '1.0', |
|
682 |
... 'oauth_verifier': token.verifier, |
|
683 |
... } |
|
684 |
>>> response = c.get("/oauth/access_token/", parameters) |
|
685 |
||
686 |
.. note:: |
|
687 |
You can use HTTP Authorization header, if you provide both, header will be |
|
688 |
checked before parameters. It depends on your needs. |
|
689 |
||
690 |
The Service Provider checks the signature and replies with an Access Token in |
|
691 |
the body of the HTTP response:: |
|
692 |
||
693 |
>>> response.status_code |
|
694 |
200 |
|
695 |
>>> response.content |
|
696 |
'oauth_token_secret=...&oauth_token=...' |
|
697 |
>>> access_token = list(Token.objects.filter(token_type=Token.ACCESS))[-1] |
|
698 |
>>> access_token.key in response.content |
|
699 |
True |
|
700 |
>>> access_token.secret in response.content |
|
701 |
True |
|
702 |
>>> access_token.user.username |
|
703 |
u'jane' |
|
704 |
||
705 |
The Consumer will not be able to request another Access Token with the same |
|
706 |
Nonce:: |
|
707 |
||
708 |
>>> from oauth_provider.models import Nonce |
|
709 |
>>> Nonce.objects.all() |
|
710 |
[<Nonce: Nonce accessnonce for ...>] |
|
711 |
>>> response = c.get("/oauth/access_token/", parameters) |
|
712 |
>>> response.status_code |
|
713 |
401 |
|
714 |
>>> response.content |
|
715 |
'Nonce already used: accessnonce' |
|
716 |
||
717 |
Nor with a missing/invalid verifier:: |
|
718 |
||
719 |
>>> parameters['oauth_nonce'] = 'yetanotheraccessnonce' |
|
720 |
>>> parameters['oauth_verifier'] = 'invalidverifier' |
|
721 |
>>> response = c.get("/oauth/access_token/", parameters) |
|
722 |
>>> response.status_code |
|
723 |
401 |
|
724 |
>>> response.content |
|
725 |
'Consumer key or token key does not match. Make sure your request token is approved. Check your verifier too if you use OAuth 1.0a.' |
|
726 |
>>> parameters['oauth_verifier'] = token.verifier # restore |
|
727 |
||
728 |
The Consumer will not be able to request an Access Token if the token is not |
|
729 |
approved:: |
|
730 |
||
731 |
>>> parameters['oauth_nonce'] = 'anotheraccessnonce' |
|
732 |
>>> token.is_approved = False |
|
733 |
>>> token.save() |
|
734 |
>>> response = c.get("/oauth/access_token/", parameters) |
|
735 |
>>> response.status_code |
|
736 |
401 |
|
737 |
>>> response.content |
|
738 |
'Consumer key or token key does not match. Make sure your request token is approved. Check your verifier too if you use OAuth 1.0a.' |
|
739 |
||
740 |
||
741 |
Accessing Protected Resources |
|
742 |
----------------------------- |
|
743 |
||
744 |
The Consumer is now ready to request the private photo. Since the photo URL is |
|
745 |
not secure (HTTP), it must use HMAC-SHA1. |
|
746 |
||
747 |
Generating Signature Base String |
|
748 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
|
749 |
||
750 |
To generate the signature, it first needs to generate the Signature Base |
|
751 |
String. The request contains the following parameters (oauth_signature |
|
752 |
excluded) which are ordered and concatenated into a normalized string:: |
|
753 |
||
754 |
>>> parameters = { |
|
755 |
... 'oauth_consumer_key': CONSUMER_KEY, |
|
756 |
... 'oauth_token': access_token.key, |
|
757 |
... 'oauth_signature_method': 'HMAC-SHA1', |
|
758 |
... 'oauth_timestamp': str(int(time.time())), |
|
759 |
... 'oauth_nonce': 'accessresourcenonce', |
|
760 |
... 'oauth_version': '1.0', |
|
761 |
... } |
|
762 |
||
763 |
||
764 |
Calculating Signature Value |
|
765 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
|
766 |
||
767 |
HMAC-SHA1 produces the following digest value as a base64-encoded string |
|
768 |
(using the Signature Base String as text and kd94hf93k423kf44&pfkkdhi9sl3r4s00 |
|
769 |
as key):: |
|
770 |
||
771 |
>>> from oauth.oauth import OAuthRequest, OAuthSignatureMethod_HMAC_SHA1 |
|
772 |
>>> oauth_request = OAuthRequest.from_token_and_callback(access_token, |
|
773 |
... http_url='http://testserver/oauth/photo/', parameters=parameters) |
|
774 |
>>> signature_method = OAuthSignatureMethod_HMAC_SHA1() |
|
775 |
>>> signature = signature_method.build_signature(oauth_request, consumer, |
|
776 |
... access_token) |
|
777 |
||
778 |
||
779 |
Requesting Protected Resource |
|
780 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
|
781 |
||
782 |
All together, the Consumer request for the photo is:: |
|
783 |
||
784 |
>>> parameters['oauth_signature'] = signature |
|
785 |
>>> response = c.get("/oauth/photo/", parameters) |
|
786 |
>>> response.status_code |
|
787 |
200 |
|
788 |
>>> response.content |
|
789 |
'Protected Resource access!' |
|
790 |
||
791 |
Otherwise, an explicit error will be raised:: |
|
792 |
||
793 |
>>> parameters['oauth_signature'] = 'wrongsignature' |
|
794 |
>>> parameters['oauth_nonce'] = 'anotheraccessresourcenonce' |
|
795 |
>>> response = c.get("/oauth/photo/", parameters) |
|
796 |
>>> response.status_code |
|
797 |
401 |
|
798 |
>>> response.content |
|
799 |
'Invalid signature. Expected signature base string: GET&http%3A%2F%2F...%2Foauth%2Fphoto%2F&oauth_...' |
|
800 |
||
801 |
>>> response = c.get("/oauth/photo/") |
|
802 |
>>> response.status_code |
|
803 |
401 |
|
804 |
>>> response.content |
|
805 |
'Invalid request parameters.' |
|
806 |
||
807 |
||
808 |
Revoking Access |
|
809 |
--------------- |
|
810 |
||
811 |
If Jane deletes the Access Token of printer.example.com, the Consumer will not |
|
812 |
be able to access the Protected Resource anymore:: |
|
813 |
||
814 |
>>> access_token.delete() |
|
815 |
>>> parameters['oauth_signature'] = signature |
|
816 |
>>> parameters['oauth_nonce'] = 'yetanotheraccessresourcenonce' |
|
817 |
>>> response = c.get("/oauth/photo/", parameters) |
|
818 |
>>> response.status_code |
|
819 |
401 |
|
820 |
>>> response.content |
|
821 |
'Invalid access token: ...' |
|
822 |
||
468 |
823 |
""" |
Up to file-list oauth_provider/views.py:
| … | … | @@ -82,6 +82,11 @@ def user_authorization(request): |
82 |
82 |
args = 'error=%s' % _('Access not granted by user.') |
83 |
83 |
except OAuthError, err: |
84 |
84 |
response = send_oauth_error(err) |
85 |
||
86 |
# OAuth 1.0a: use the token's callback if confirmed |
|
87 |
if token.callback_confirmed: |
|
88 |
callback = token.callback |
|
89 |
||
85 |
90 |
if callback: |
86 |
91 |
if "?" in callback: |
87 |
92 |
url_delimiter = "&" |
