[#7272] Add authorization views and improve validations
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index fab1757..8f462de 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -50,6 +50,7 @@
from allura.lib.widgets import (
SubscriptionForm,
OAuthApplicationForm,
+ OAuth2ApplicationForm,
OAuthRevocationForm,
LoginForm,
ForgottenPasswordForm,
@@ -74,6 +75,7 @@
subscription_form = SubscriptionForm()
registration_form = forms.RegistrationForm(action='/auth/save_new')
oauth_application_form = OAuthApplicationForm(action='register')
+ oauth2_application_form = OAuth2ApplicationForm(action='register')
oauth_revocation_form = OAuthRevocationForm(
action='/auth/preferences/revoke_oauth')
change_personal_data_form = forms.PersonalDataForm()
@@ -112,6 +114,7 @@
self.user_info = UserInfoController()
self.subscriptions = SubscriptionsController()
self.oauth = OAuthController()
+ self.oauth2 = OAuth2Controller()
if asbool(config.get('auth.allow_user_to_disable_account', False)):
self.disable = DisableAccountController()
@@ -1404,6 +1407,61 @@
redirect('.')
+class OAuth2Controller(BaseController):
+ def _check_security(self):
+ require_authenticated()
+
+ def _revoke_all(self, client_id):
+ M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
+ M.OAuth2Token.query.remove({'client_id': client_id})
+
+ @with_trailing_slash
+ @expose('jinja:allura:templates/oauth2_applications.html')
+ def index(self, **kw):
+ c.form = F.oauth2_application_form
+ provider = plugin.AuthenticationProvider.get(request)
+ clients = M.OAuth2Client.for_user(c.user)
+ model = []
+
+ for client in clients:
+ authorization = M.OAuth2AuthorizationCode.query.get(client_id=client.client_id)
+ token = M.OAuth2Token.query.get(client_id=client.client_id)
+ model.append(dict(client=client, authorization=authorization, token=token))
+
+ return dict(
+ menu=provider.account_navigation(),
+ model=model
+ )
+
+ @expose()
+ @require_post()
+ @validate(F.oauth2_application_form, error_handler=index)
+ def register(self, application_name=None, application_description=None, redirect_url=None, **kw):
+ M.OAuth2Client(name=application_name,
+ description=application_description,
+ redirect_uris=[redirect_url])
+ flash('Oauth2 Client registered')
+ redirect('.')
+
+ @expose()
+ @require_post()
+ def do_client_action(self, _id=None, deregister=None, revoke=None):
+ client = M.OAuth2Client.query.get(client_id=_id)
+ if client is None or client.user_id != c.user._id:
+ flash('Invalid client ID', 'error')
+ redirect('.')
+
+ if deregister:
+ self._revoke_all(_id)
+ client.delete()
+ flash('Client deleted and access tokens revoked.')
+
+ if revoke:
+ self._revoke_all(_id)
+ flash('Access tokens revoked.')
+ redirect('.')
+
+
class DisableAccountController(BaseController):
def _check_security(self):
diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py
index 0f29c16..e77c8be 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -19,7 +19,7 @@
import json
import logging
from datetime import datetime, timedelta
-from urllib.parse import unquote, urlparse, parse_qs
+from urllib.parse import unquote, urlparse, parse_qs, parse_qsl
import oauthlib.oauth1
import oauthlib.oauth2
@@ -261,7 +261,7 @@
return request.uri
def invalidate_authorization_code(self, client_id: str, code: str, request: oauthlib.common.Request, *args, **kwargs) -> None:
- return
+ M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
def authenticate_client(self, request: oauthlib.common.Request, *args, **kwargs) -> bool:
client_id = request.body['client_id']
@@ -273,32 +273,51 @@
return True
def validate_code(self, client_id: str, code: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
- return True
+ authorization = M.OAuth2AuthorizationCode.query.get({'client_id': client_id})
+ return authorization.expires_at <= datetime.utcnow()
def confirm_redirect_uri(self, client_id: str, code: str, redirect_uri: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
return True
def save_authorization_code(self, client_id: str, code, request: oauthlib.common.Request, *args, **kwargs) -> None:
- auth_code = M.OAuth2AuthorizationCode(
- client_id = client_id,
- authorization_code = code['code'],
- expires_at = datetime.utcnow() + timedelta(minutes=10)
- )
- request.client_id = client_id
- session(auth_code).flush()
- log.info(f'Saving new authorization code for client: {request.client_id}')
+ authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id)
+
+ if not authorization:
+ auth_code = M.OAuth2AuthorizationCode(
+ client_id = client_id,
+ authorization_code = code['code'],
+ expires_at = datetime.utcnow() + timedelta(minutes=10)
+ )
+ session(auth_code).flush()
+ log.info(f'Saving new authorization code for client: {client_id}')
+ else:
+ log.info(f'Updating authorization code for {client_id}')
+ log.info(f'Current authorization code: {authorization.authorization_code}')
+ log.info(f'New authorization code: {code["code"]}')
+ M.OAuth2AuthorizationCode.query.update(
+ {'client_id': client_id},
+ {'$set': {'authorization_code': code['code'], 'expires_at': datetime.utcnow() + timedelta(minutes=10)}})
+ log.info(f'Updating authorization code for client: {client_id}')
def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kwargs) -> object:
- bearer_token = M.OAuth2Token(
- client_id = request.client_id,
- scopes = token.get('scope', []),
- access_token = token.get('access_token'),
- refresh_token = token.get('refresh_token'),
- expires_at = datetime.utcfromtimestamp(token.get('expires_in'))
- )
+ current_token = M.OAuth2Token.query.get(client_id=request.client_id)
- session(bearer_token).flush()
- log.info(f'Saving new bearer token for client: {request.client_id}')
+ if not current_token:
+ bearer_token = M.OAuth2Token(
+ client_id = request.client_id,
+ scopes = token.get('scope', []),
+ access_token = token.get('access_token'),
+ refresh_token = token.get('refresh_token'),
+ expires_at = datetime.utcfromtimestamp(token.get('expires_in'))
+ )
+
+ session(bearer_token).flush()
+ log.info(f'Saving new bearer token for client: {request.client_id}')
+ else:
+ M.OAuth2Token.query.update(
+ {'client_id': request.client_id},
+ {'$set': {'access_token': token.get('access_token'), 'expires_at': datetime.utcfromtimestamp(token.get('expires_in')), 'refresh_token': token.get('refresh_token')}})
+ log.info(f'Updating bearer token for client: {request.client_id}')
class AlluraOauth1Server(oauthlib.oauth1.WebApplicationServer):
@@ -442,7 +461,7 @@
def server(self):
return oauthlib.oauth2.WebApplicationServer(Oauth2Validator())
- @expose('json:')
+ @expose('jinja:allura:templates/oauth2_authorize.html')
def authorize(self, **kwargs):
security.require_authenticated()
json_body = None
@@ -455,12 +474,45 @@
try:
scopes, credentials = self.server.validate_authorization_request(uri=request.url, http_method=request.method, headers=request.headers, body=json_body)
- headers, body, status = self.server.create_authorization_response(
- uri=request.url, http_method=request.method, body=json_body, headers=request.headers, scopes=[], credentials=credentials
+ client_id = request.params.get('client_id')
+ client = M.OAuth2Client.query.get(client_id=client_id)
+
+ # We need to save the credentials to the current session so we can use it later in the POST request.
+ # We also need to use __dict__ because the internal oauthlib request object cannot be directly serialized
+ # and saved to Ming
+ credentials['request'] = credentials['request'].__dict__
+ M.OAuth2Client.set_credentials(client_id, credentials)
+
+ return dict(
+ credentials=credentials,
+ client=client
)
except Exception as e:
log.exception(e)
+ @expose('jinja:allura:templates/oauth2_authorize_ok.html')
+ @require_post()
+ def do_authorize(self, yes=None, no=None):
+ security.require_authenticated()
+
+ client_id = request.params['client_id']
+ client = M.OAuth2Client.query.get(client_id=client_id)
+
+ if no:
+ flash(f'{client.name} NOT AUTHORIZED', 'error')
+ redirect('/auth/oauth2/')
+
+ try:
+ headers, body, status = self.server.create_authorization_response(
+ uri=request.url, http_method=request.method, body=request.body, headers=request.headers, scopes=[], credentials=client.credentials
+ )
+
+ qs_params = dict(parse_qsl(headers['Location']))
+ return dict(client=client, authorization_code=qs_params.get('code', ''))
+ except Exception as ex:
+ log.exception(ex)
+
+
@expose('json:')
@require_post()
def token(self, **kwargs):
diff --git a/Allura/allura/lib/widgets/__init__.py b/Allura/allura/lib/widgets/__init__.py
index 3e8e798..e744376 100644
--- a/Allura/allura/lib/widgets/__init__.py
+++ b/Allura/allura/lib/widgets/__init__.py
@@ -17,10 +17,10 @@
from .discuss import Post, Thread
from .subscriptions import SubscriptionForm
-from .oauth_widgets import OAuthApplicationForm, OAuthRevocationForm
+from .oauth_widgets import OAuthApplicationForm, OAuth2ApplicationForm, OAuthRevocationForm
from .auth_widgets import LoginForm, ForgottenPasswordForm, DisableAccountForm
from .vote import VoteForm
__all__ = [
- 'Post', 'Thread', 'SubscriptionForm', 'OAuthApplicationForm', 'OAuthRevocationForm', 'LoginForm',
- 'ForgottenPasswordForm', 'DisableAccountForm', 'VoteForm']
+ 'Post', 'Thread', 'SubscriptionForm', 'OAuthApplicationForm', 'OAuth2ApplicationForm',
+ 'OAuthRevocationForm', 'LoginForm', 'ForgottenPasswordForm', 'DisableAccountForm', 'VoteForm']
diff --git a/Allura/allura/lib/widgets/oauth_widgets.py b/Allura/allura/lib/widgets/oauth_widgets.py
index f04fed8..201bc35 100644
--- a/Allura/allura/lib/widgets/oauth_widgets.py
+++ b/Allura/allura/lib/widgets/oauth_widgets.py
@@ -41,3 +41,14 @@
class fields(ew_core.NameList):
_id = ew.HiddenField()
+
+
+class OAuth2ApplicationForm(ForgeForm):
+ submit_text = 'Register new Application'
+ style = 'wide'
+
+ class fields(ew_core.NameList):
+ application_name = ew.TextField(label='Application Name',
+ validator=V.UniqueOAuthApplicationName())
+ application_description = AutoResizeTextarea(label='Application Description')
+ redirect_url = ew.TextField(label='Redirect URL')
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index 170718e..08791f7 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -162,13 +162,31 @@
query: 'Query[OAuth2Client]'
_id = FieldProperty(S.ObjectId)
- client_id = FieldProperty(str)
+ client_id = FieldProperty(str, if_missing=lambda: h.nonce(20))
+ credentials = FieldProperty(S.Anything)
+ name = FieldProperty(str)
+ description = FieldProperty(str, if_missing='')
user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
- grant_type = FieldProperty(str)
- response_type = FieldProperty(str)
+ grant_type = FieldProperty(str, if_missing='authorization_code')
+ response_type = FieldProperty(str, if_missing='code')
scopes = FieldProperty([str])
redirect_uris = FieldProperty([str])
+ @classmethod
+ def for_user(cls, user=None):
+ if user is None:
+ user = c.user
+ return cls.query.find(dict(user_id=user._id)).all()
+
+ @classmethod
+ def set_credentials(cls, client_id, credentials):
+ cls.query.update({'client_id': client_id }, {'$set': {'credentials': credentials}})
+
+ @property
+ def description_html(self):
+ return g.markdown.cached_convert(self, 'description')
+
+
class OAuth2AuthorizationCode(MappedClass):
class __mongometa__:
session = main_orm_session
diff --git a/Allura/allura/templates/oauth2_applications.html b/Allura/allura/templates/oauth2_applications.html
new file mode 100644
index 0000000..cb16be3
--- /dev/null
+++ b/Allura/allura/templates/oauth2_applications.html
@@ -0,0 +1,127 @@
+{#-
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-#}
+{% set hide_left_bar = True %}
+{% extends "allura:templates/user_account_base.html" %}
+
+{% block title %}{{c.user.username}} / Applications {% endblock %}
+
+{% block header %}OAuth2 applications registered for {{c.user.username}}{% endblock %}
+
+{% block extra_css %}
+<style type="text/css">
+ table {
+ border: 1px solid #e5e5e5;
+ }
+ th {
+ text-align: left;
+ width: 10em;
+ padding: 5px;
+ border: 1px solid #e5e5e5;
+ }
+ tr.description p {
+ padding-left: 0;
+ }
+ tr.description p:last-child {
+ padding-bottom: 0;
+ }
+ tr.controls input[type="submit"] {
+ margin-bottom: 0;
+ }
+</style>
+{% endblock %}
+
+{% block extra_js %}
+<script type="text/javascript">
+ $(function() {
+ var btnClicked;
+
+ // The click event will always trigger before the submit event, this will give us the chance to
+ // figure out which button was clicked in order to display the correct confirmation dialog
+ $('#deregister,#revoke').click(function(){
+ btnClicked = this.name;
+ });
+
+ $('.client_action').submit(function(e) {
+ var confirmMsg;
+
+ if(btnClicked === 'deregister') {
+ confirmMsg = 'Deregister client?. This action will also revoke authorization and access tokens.'
+ }
+
+ if(btnClicked === 'revoke') {
+ confirmMsg = "Revoke client's authorization codes and access tokens?. This action will not delete the current client."
+ }
+
+ var ok = confirm(confirmMsg)
+ if(!ok) {
+ e.preventDefault();
+ return false;
+ }
+ });
+ })
+</script>
+{% endblock %}
+
+{% block content %}
+ {{ super() }}
+
+ <h2>My Clients</h2>
+ <p>
+ These are the clients you have registered. They can request authorization
+ for a user by sending the client id and a response type.
+ Once you have an authorization code, you can generate an access token to give your client access
+ to your account and use a refresh token to generate a new one each time it expires. Note, however,
+ that you must be careful with access tokens, since anyone who has the token can
+ access your account as that client.
+ </p>
+ {% for app in model %}
+ <table class="registered_app">
+ <tr><th>Name:</th><td>{{app.client.name}}</td></tr>
+ <tr class="description"><th>Description:</th><td>{{app.client.description }}</td></tr>
+ <tr class="client_id"><th>Client ID:</th><td>{{app.client.client_id}}</td></tr>
+ <tr class="redirect_url"><th>Redirect URL:</th><td>{{app.client.redirect_uris[0] if app.client.redirect_uris else ''}}</td></tr>
+ <tr class="grant_type"><th>Grant Type:</th><td>{{app.client.grant_type}}</td></tr>
+
+ {% if app.authorization %}
+ <tr class="authorization_code"><th>Authorization Code:</th><td>{{app.authorization.authorization_code}}</td></tr>
+ <tr class="authorization_code_expires"><th>Authorization Code Expires At:</th><td>{{app.authorization.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
+ {% endif %}
+
+ {% if app.access_token %}
+ <tr class="access_token"><th>Access Token:</th><td>{{app.token.access_token}}</td></tr>
+ <tr class="access_token_expires"><th>Access Token Expires At:</th><td>{{app.token.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
+ <tr class="refresh_token"><th>Refresh Token:</th><td>{{app.token.refresh_token}}</td></tr>
+ {% endif %}
+
+ <tr class="controls">
+ <td colspan="2">
+ <form method="POST" action="do_client_action" class="client_action">
+ <input type="hidden" name="_id" value="{{app.client.client_id}}"/>
+ <input id="deregister" type="submit" name="deregister" value="Deregister Client"/>
+ <input id="revoke" type="submit" name="revoke" value="Revoke Access"/>
+ {{lib.csrf_token()}}
+ </form>
+ </td>
+ </tr>
+ </table>
+ {% endfor %}
+
+ <h2>Register New Application</h2>
+ {{ c.form.display() }}
+{% endblock %}
diff --git a/Allura/allura/templates/oauth2_authorize.html b/Allura/allura/templates/oauth2_authorize.html
new file mode 100644
index 0000000..28cc650
--- /dev/null
+++ b/Allura/allura/templates/oauth2_authorize.html
@@ -0,0 +1,62 @@
+{#-
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-#}
+{% set hide_left_bar = True %}
+{% extends g.theme.master %}
+{% set legacy_chrome = False %}
+{% block extra_css %}
+<style>
+.pad{ min-height: 0 }
+.flex-container{ display: flex; justify-content: center; align-items: center; flex-direction: column; }
+.extra-pad{ padding: 10px; }
+</style>
+{% endblock %}
+{% block title %} Authorize third-party application? {% endblock %}
+
+{% block header %}Authorize third party application{% endblock %}
+{% block header_classes %} title {% endblock %}
+
+
+{% block content %}
+<div class="extra-pad">
+<h3>
+ The application "<strong>{{ client.name }}</strong>" wants to access your account.
+</h3>
+
+<p>
+ If you grant them access, they will be able to perform any actions on
+ the site as though they were logged in as you. Do you wish to grant
+ them access?
+</p>
+
+<br style="clear:both"/>
+<div class="flex-container">
+ <p><strong>App Name:</strong> {{client.name}}</p>
+ <p><strong>Description:</strong> <br> {{client.description_html}}</p>
+</div>
+<br style="clear:both"/>
+<div class="flex-container">
+ <form method="POST" action="do_authorize">
+ <input type="hidden" name="client_id" value="{{client.client_id}}"/>
+ <input type="submit" class="submit" style="background: #ccc;color:#555" name="no" value="No, do not authorize {{ client.name }}">
+ <input type="submit" class="button" name="yes" value="Yes, authorize {{ client.name }}"><br>
+ {{lib.csrf_token()}}
+ </form>
+</div>
+</div>
+{% endblock %}
diff --git a/Allura/allura/templates/oauth2_authorize_ok.html b/Allura/allura/templates/oauth2_authorize_ok.html
new file mode 100644
index 0000000..6334b35
--- /dev/null
+++ b/Allura/allura/templates/oauth2_authorize_ok.html
@@ -0,0 +1,35 @@
+{#-
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-#}
+{% set hide_left_bar = True %}
+{% extends g.theme.master %}
+
+{% block title %} Third-party client authorized. {% endblock %}
+
+{% block header %}Third-party client authorized.{% endblock %}
+
+{% block content %}
+<p>You have authorized {{ client.name }} access to your account. If you wish
+ to revoke this access at any time, please visit
+ <a href="/auth/preferences">user preferences</a>
+ and click 'revoke access'.</p>
+<p>You can use the following authorization code to request an access token from /rest/oauth2/token.</p>
+<h2>Authorization Code: {{ authorization_code }}</h2>
+<p>Please be aware that the authorization code will be valid for 10 minutes.</p>
+<a href="/auth/preferences/">Return to preferences</a>
+{% endblock %}