david / django-oauth (http://oauth.net/)

Support of OAuth in Django.

Clone this repository (size: 114.5 KB): HTTPS / SSH
$ hg clone http://code.welldev.org/django-oauth/
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.
David Larlet / david
6 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
        raise OAuthError('Consumer key or token key does not match. Make sure your request token is approved too.')
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 too.'
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 = "&"