[#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 %}