Skip to content

Commit b50a014

Browse files
author
Francis Tseng
committed
(prototype) oauth provider implemented, closes #22
1 parent 91f05c5 commit b50a014

File tree

9 files changed

+590
-14
lines changed

9 files changed

+590
-14
lines changed

argos/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from flask.ext.sqlalchemy import SQLAlchemy
55
from flask.ext.security import SQLAlchemyUserDatastore, Security
6+
from flask_oauthlib.provider import OAuth2Provider
67

78
# Initialize the database and declarative Base class
89
db = SQLAlchemy(app)

argos/web/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
from flask import Flask
66

7-
app = Flask(__name__,
8-
static_folder='static',
7+
app = Flask(__name__,
8+
static_folder='static',
99
static_url_path='')
1010

1111
app.config.update(APP)

argos/web/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from argos.core.models import *
22

33
from argos.web.models.user import Role, User, Auth
4+
from argos.web.models.oauth import Client, Grant, Token

argos/web/models/oauth.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from argos.web.app import app
2+
from argos.datastore import db, Model
3+
4+
class InvalidScope(Exception):
5+
def __init__(self, message):
6+
Exception.__init__(self)
7+
self.message = message
8+
self.status_code = 400
9+
10+
class InvalidGrantType(Exception):
11+
def __init__(self, message):
12+
Exception.__init__(self)
13+
self.message = message
14+
self.status_code = 400
15+
16+
VALID_SCOPES = ['userinfo']
17+
18+
class Client(db.Model):
19+
client_id = db.Column(db.String(40), primary_key=True)
20+
client_secret = db.Column(db.String(55), unique=True, index=True, nullable=False)
21+
22+
user_id = db.Column(db.ForeignKey('user.id'))
23+
user = db.relationship('User')
24+
25+
name = db.Column(db.String(40))
26+
desc = db.Column(db.String(400))
27+
28+
is_confidential = db.Column(db.Boolean)
29+
30+
_redirect_uris = db.Column(db.Text)
31+
_default_scopes = db.Column(db.Text)
32+
33+
_allowed_grant_types = db.Column(db.Text)
34+
35+
def validate_scopes(self, scopes):
36+
for scope in scopes:
37+
if scope not in VALID_SCOPES:
38+
raise InvalidScope('Invalid scope.')
39+
return True
40+
41+
def validate_grant_type(self, grant_type):
42+
if grant_type not in self.allowed_grant_types:
43+
raise InvalidGrantType('Invalid or missing grant type.')
44+
return True
45+
46+
@property
47+
def client_type(self):
48+
if self.is_confidential:
49+
return 'confidential'
50+
return 'public'
51+
52+
@property
53+
def redirect_uris(self):
54+
if self._redirect_uris:
55+
return self._redirect_uris.split()
56+
return []
57+
58+
@property
59+
def default_redirect_uri(self):
60+
return self.redirect_uris[0]
61+
62+
@property
63+
def default_scopes(self):
64+
if self._default_scopes:
65+
return self._default_scopes.split()
66+
return []
67+
68+
@property
69+
def allowed_grant_types(self):
70+
if self._allowed_grant_types:
71+
return self._allowed_grant_types.split()
72+
return []
73+
74+
75+
class Grant(db.Model):
76+
id = db.Column(db.Integer, primary_key=True)
77+
78+
user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'))
79+
user = db.relationship('User')
80+
81+
client_id = db.Column(db.ForeignKey('client.client_id'), nullable=False)
82+
client = db.relationship('Client')
83+
84+
code = db.Column(db.String(255), index=True, nullable=False)
85+
redirect_uri = db.Column(db.String(255))
86+
expires = db.Column(db.DateTime)
87+
88+
_scopes = db.Column(db.Text)
89+
90+
def delete(self):
91+
db.session.delete(self)
92+
db.session.commit
93+
return self
94+
95+
@property
96+
def scopes(self):
97+
if self._scopes:
98+
return self._scopes.split()
99+
return []
100+
101+
102+
class Token(db.Model):
103+
id = db.Column(db.Integer, primary_key=True)
104+
105+
client_id = db.Column(db.ForeignKey('client.client_id'), nullable=False)
106+
client = db.relationship('Client')
107+
108+
user_id = db.Column(db.ForeignKey('user.id'))
109+
user = db.relationship('User')
110+
111+
# Currently OAuthLib only supports bearer tokens.
112+
token_type = db.Column(db.String(40))
113+
114+
access_token = db.Column(db.String(255), unique=True)
115+
refresh_token = db.Column(db.String(255), unique=True)
116+
expires = db.Column(db.DateTime)
117+
_scopes = db.Column(db.Text)
118+
119+
@property
120+
def scopes(self):
121+
if self._scopes:
122+
return self._scopes.split()
123+
return []

argos/web/routes/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ def internal_error(error):
1111
app.logger.exception(error)
1212
return jsonify(status=500, message='Internal server error.'), 500
1313

14-
from argos.web.routes import api, auth
14+
from argos.web.routes import api, auth, oauth

argos/web/routes/auth.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,6 @@
1313
facebook = oauth.remote_app('facebook', app_key='FACEBOOK')
1414
google = oauth.remote_app('google', app_key='GOOGLE')
1515

16-
@app.before_request
17-
def before_request():
18-
pass
19-
# Get the current user before each request.
20-
#g.user = None
21-
#for provider in ['twitter', 'facebook', 'google']:
22-
#oauth = '{0}_oauth'.format(provider)
23-
#if oauth in session:
24-
#g.user = session[oauth]
25-
#break
26-
2716
@app.route('/oauth/twitter')
2817
def twitter_authorize():
2918
return twitter.authorize(callback=url_for('twitter_authorized', _external=True))

argos/web/routes/oauth.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
from argos.web.app import app
2+
from argos.web.models.oauth import Grant, Client, Token, InvalidScope, InvalidGrantType
3+
from argos.datastore import db
4+
5+
from flask import jsonify, request, render_template, abort, redirect, url_for
6+
from flask_oauthlib.provider import OAuth2Provider
7+
from flask_security.core import current_user
8+
from flask.ext.security import login_required, LoginForm
9+
from flask_security.utils import login_user
10+
from werkzeug.security import gen_salt
11+
12+
from datetime import datetime, timedelta
13+
14+
oauth = OAuth2Provider(app)
15+
GRANT_LIFETIME = 3600
16+
17+
@app.errorhandler(InvalidScope)
18+
def handle_invalid_scope(error):
19+
response = jsonify({'message': error.message})
20+
response.status_code = error.status_code
21+
return response
22+
23+
@app.errorhandler(InvalidGrantType)
24+
def handle_invalid_grant_type(error):
25+
response = jsonify({'message': error.message})
26+
response.status_code = error.status_code
27+
return response
28+
29+
@oauth.clientgetter
30+
def load_client(client_id):
31+
return Client.query.filter_by(client_id=client_id).first()
32+
33+
@oauth.grantgetter
34+
def load_grant(client_id, code):
35+
return Grant.query.filter_by(client_id=client_id, code=code).first()
36+
37+
@oauth.grantsetter
38+
def save_grant(client_id, code, request, *args, **kwargs):
39+
expires = datetime.utcnow() + timedelta(seconds=GRANT_LIFETIME)
40+
grant = Grant(
41+
client_id=client_id,
42+
code=code['code'],
43+
redirect_uri=request.redirect_uri,
44+
_scopes=' '.join(request.scopes),
45+
user=current_user,
46+
expires=expires
47+
)
48+
db.session.add(grant)
49+
db.session.commit()
50+
return grant
51+
52+
@oauth.tokengetter
53+
def load_token(access_token=None, refresh_token=None):
54+
if access_token:
55+
return Token.query.filter_by(access_token=access_token).first()
56+
elif refresh_token:
57+
return Token.query.filter_by(access_token=refresh_token).first()
58+
59+
@oauth.tokensetter
60+
def save_token(token, request, *args, **kwargs):
61+
# Ensure that each client has only one token connected to a user.
62+
tokens = Token.query.filter_by(
63+
client_id=request.client.client_id,
64+
user_id=request.user.id
65+
)
66+
for token in tokens:
67+
db.session.delete(token)
68+
69+
expires_in = token.pop('expires_in')
70+
expires = datetime.utcnow() + timedelta(seconds=expires_in)
71+
72+
token = Token(
73+
access_token=token['access_token'],
74+
refresh_token=token['refresh_token'],
75+
token_type=token['token_type'],
76+
_scopes=token['scope'],
77+
expires=expires,
78+
client_id=request.client.client_id,
79+
user_id=request.user.id
80+
)
81+
db.session.add(token)
82+
db.session.commit()
83+
return token
84+
85+
@app.route('/oauth/token')
86+
@oauth.token_handler
87+
def access_token():
88+
return None
89+
90+
@app.route('/oauth/authorize', methods=['GET', 'POST'])
91+
@login_required
92+
@oauth.authorize_handler
93+
def authorize(*args, **kwargs):
94+
"""
95+
Authorization endpoint for OAuth2.
96+
Successful interaction with this endpoint yields an access token
97+
for the requesting client.
98+
99+
Please note that I am not very confident of this current approach's
100+
security; it involves a shaky workaround to implement the resource owner
101+
password credentials (ROPC) OAuth2 flow for the official mobile app.
102+
103+
Mobile applications are considered "public" clients because their client
104+
credentials (client id and client secret) cannot reliably be kept secure,
105+
since if they are bundled with the application they are potentially accessible
106+
to anyone who has the app on their phone.
107+
108+
But the ROPC flow works like this:
109+
110+
* User opens the mobile app and is greated with a native login view.
111+
* User enters their credentials and hits "Login".
112+
* POST to `/oauth/authorize?client_id=<CLIENT_ID>&response_type=code&grant_type=resource_owner_credentials&scope=userinfo&redirect_uri=<your redirect uri>`, with data: `{"email": <user email>, "password": <user password>}`
113+
* Server finds client with the specified client id, checks that the client is allowed the ROPC grant type, and if so, authenticates the user with the provided credentials.
114+
* If successful, the server responds with the header `Location: <your redirect uri>/?code=<your authorization code>`
115+
* In the mobile app, you can extract the authorization code and exchange it for the access token by sending a GET request to `/oauth/token?code=<your authorization code>&grant_type=authorization_code&client_id=<your client_id>&redirect_uri=<your redirect uri>`
116+
* If successful, the server responds with something like: `{"refresh_token": "Z9QAolFevdLXjO7OR1ImJ1pkqc248j", "scope": "userinfo", "access_token": "wvjTny7CXEVEQSfyxC1MSP11NEPnlj", "token_type": "Bearer"}`
117+
118+
Clearly this is not the ideal flow and not really according to OAuth2 specifications. Anyone can discover and use the client id to imitate the official client since there is no (and cannot be any) verification of client authenticity with a client secret. Unless the provider server is secured with SSL/HTTPS, passwords are sent out in the open. It is only a temporary solution.
119+
120+
A potential long-term solution is to use OAuth2's "client credentials" flow and consider each *installation* of the mobile application as a *separate* client. Thus each installation has its own unique client id and client secret, relevant only for that particular user. This still may not be very good (haven't thought it all the way through).
121+
122+
For some more info see:
123+
124+
* https://stackoverflow.com/questions/14574846/client-authentication-on-public-client
125+
* https://stackoverflow.com/questions/6190381/how-to-keep-the-client-credentials-confidential-while-using-oauth2s-resource-o
126+
"""
127+
128+
# NB: request.values refers to values from both
129+
# the response body and the url parameters.
130+
client_id = request.values.get('client_id')
131+
client = Client.query.get(client_id)
132+
grant_type = request.values.get('grant_type')
133+
134+
# Check to see if the requested scope(s) are permitted
135+
# for this client.
136+
# Since this is a workaround (see below), this looks a bit weird.
137+
# But if the expected kwargs isn't processed (flask_oauthlib only processes them
138+
# if it is a GET request), collect the scope information another way.
139+
# GET POST
140+
scopes = kwargs.get('scopes') or request.values.get('scope').split()
141+
client.validate_scopes(scopes)
142+
143+
# Check to see if the requested grant type is permitted
144+
# for this client.
145+
client.validate_grant_type(grant_type)
146+
147+
if request.method == 'GET':
148+
kwargs['client'] = client
149+
if grant_type == 'authorization_code':
150+
# The user must authenticate herself,
151+
# if not already authenticated.
152+
if not current_user.is_authenticated():
153+
return redirect(url_for('security.login', next=url_for('authorize')))
154+
155+
kwargs['user'] = current_user
156+
157+
return render_template('authorize.html', **kwargs)
158+
159+
response = jsonify({'message': 'Invalid grant type for this request. Perhaps you mean a grant_type of `authorization_code`?'})
160+
response.status_code = 400
161+
return response
162+
163+
elif request.method == 'POST':
164+
165+
# Authenticate on behalf of the user.
166+
# ONLY TRUSTED CLIENTS should be allowed this grant type.
167+
# i.e. only official clients, all others should be using
168+
# grant type of `authorization_code`.
169+
# This is enforced since clients are by default restrited to only
170+
# the `authorization_code` grant type, unless explicitly set otherwise.
171+
# Note: this is a workaround since flask_oauthlib does not support the "password"
172+
# grant type at the moment (which is equivalent to "resource_owner_credentials").
173+
if grant_type == 'resource_owner_credentials':
174+
form = LoginForm(request.form, csrf_enabled=False)
175+
if form.validate_on_submit():
176+
login_user(form.user)
177+
return True
178+
else:
179+
print(form.errors)
180+
return False
181+
182+
# Otherwise, assume this request is coming from
183+
# the authorization_code's authorize form.
184+
else:
185+
confirm = request.form.get('confirm', 'no')
186+
return confirm == 'yes'
187+
188+
189+
@app.route('/client')
190+
@login_required
191+
def client():
192+
if not current_user.is_authenticated():
193+
return redirect('/')
194+
client = Client(
195+
client_id=gen_salt(40),
196+
client_secret=gen_salt(50),
197+
_redirect_uris='http://localhost:5000/authorized',
198+
_default_scopes='userinfo',
199+
_allowed_grant_types='authorization_code refresh_token',
200+
user_id=current_user.id,
201+
)
202+
db.session.add(client)
203+
db.session.commit()
204+
return jsonify(
205+
client_id=client.client_id,
206+
client_secret=client.client_secret,
207+
)

0 commit comments

Comments
 (0)