|
| 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