This is an automated email from the ASF dual-hosted git repository. brondsem pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/allura.git
commit 8178912285d859c55a365f67f896e1db373c307c Author: Carlos Cruz <carlos.c...@slashdotmedia.com> AuthorDate: Mon Apr 1 18:39:49 2024 +0000 [#7272] Add authorization views and improve validations --- Allura/allura/controllers/auth.py | 58 +++++++++++ Allura/allura/controllers/rest.py | 98 +++++++++++++---- Allura/allura/lib/widgets/__init__.py | 6 +- Allura/allura/lib/widgets/oauth_widgets.py | 11 ++ Allura/allura/model/oauth.py | 24 ++++- Allura/allura/templates/oauth2_applications.html | 127 +++++++++++++++++++++++ Allura/allura/templates/oauth2_authorize.html | 62 +++++++++++ Allura/allura/templates/oauth2_authorize_ok.html | 35 +++++++ 8 files changed, 392 insertions(+), 29 deletions(-) diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py index fab1757e8..8f462def0 100644 --- a/Allura/allura/controllers/auth.py +++ b/Allura/allura/controllers/auth.py @@ -50,6 +50,7 @@ from allura.lib.security import HIBPClient, HIBPCompromisedCredentials, HIBPClie from allura.lib.widgets import ( SubscriptionForm, OAuthApplicationForm, + OAuth2ApplicationForm, OAuthRevocationForm, LoginForm, ForgottenPasswordForm, @@ -74,6 +75,7 @@ class F: 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 @@ class AuthController(BaseController): 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 @@ class OAuthController(BaseController): 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 0f29c1676..e77c8bea9 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 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): 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 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): 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) + + 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}') + 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 @@ class Oauth2Negotiator: 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 @@ class Oauth2Negotiator: 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 3e8e79881..e7443768f 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 f04fed87e..201bc35ce 100644 --- a/Allura/allura/lib/widgets/oauth_widgets.py +++ b/Allura/allura/lib/widgets/oauth_widgets.py @@ -41,3 +41,14 @@ class OAuthRevocationForm(ForgeForm): 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 170718ea5..08791f76e 100644 --- a/Allura/allura/model/oauth.py +++ b/Allura/allura/model/oauth.py @@ -162,13 +162,31 @@ class OAuth2Client(MappedClass): 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 000000000..cb16be3db --- /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 000000000..28cc65055 --- /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 000000000..6334b35c9 --- /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 %}