From c5965f9406410e04c592e110a2e9735ff4eeca03 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Tue, 12 Dec 2017 11:11:58 +0100
Subject: [PATCH] Add token revoking

- All created tokens are stored in the database.
  To revoke a token, we just delete it from the database.
  Tokens not found in the database are thus considered unvalid / revoked.

- Add button to copy generated token to the clipboard

- Redesign profile page
---
 app/admin/views.py               |  5 ++
 app/api/main.py                  |  6 +-
 app/factory.py                   |  3 +-
 app/models.py                    | 42 ++++++++++----
 app/settings.py                  |  2 +
 app/static/js/profile.js         | 20 +++++++
 app/templates/users/profile.html | 74 ++++++++++++++++++++++---
 app/tokens.py                    | 94 ++++++++++++++++++++++++++++++++
 app/users/forms.py               | 17 ++++++
 app/users/views.py               | 39 ++++++++++---
 10 files changed, 272 insertions(+), 30 deletions(-)
 create mode 100644 app/static/js/profile.js
 create mode 100644 app/tokens.py
 create mode 100644 app/users/forms.py

diff --git a/app/admin/views.py b/app/admin/views.py
index a42a80d..bbecc9a 100644
--- a/app/admin/views.py
+++ b/app/admin/views.py
@@ -64,6 +64,11 @@ class UserAdmin(AdminModelView):
     can_delete = False
 
 
+class TokenAdmin(AdminModelView):
+    can_create = False
+    can_edit = False
+
+
 class ItemAdmin(AdminModelView):
 
     # Replace TextAreaField (default for Text) with StringField
diff --git a/app/api/main.py b/app/api/main.py
index 494c83d..f097d5f 100644
--- a/app/api/main.py
+++ b/app/api/main.py
@@ -11,11 +11,11 @@ This module implements the application API.
 """
 import sqlalchemy as sa
 from flask import (current_app, Blueprint, jsonify, request)
-from flask_jwt_extended import create_access_token, jwt_required
+from flask_jwt_extended import jwt_required
 from flask_ldap3_login import AuthenticationResponseStatus
 from ..extensions import ldap_manager, db
 from ..models import Item, Manufacturer, Model, Location, Status, Action, Network, Host
-from .. import utils
+from .. import utils, tokens
 from ..decorators import jwt_groups_accepted
 
 bp = Blueprint('api', __name__)
@@ -94,7 +94,7 @@ def login():
             response.user_id,
             response.user_info,
             response.user_groups)
-        payload = {'access_token': create_access_token(identity=user.id)}
+        payload = {'access_token': tokens.generate_access_token(identity=user.id)}
         return jsonify(payload), 200
     raise utils.CSEntryError('Invalid credentials', status_code=401)
 
diff --git a/app/factory.py b/app/factory.py
index 3ce227d..8c73d05 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -14,7 +14,7 @@ from flask import Flask
 from whitenoise import WhiteNoise
 from . import settings, models
 from .extensions import db, migrate, login_manager, ldap_manager, bootstrap, admin, mail, jwt, toolbar
-from .admin.views import (AdminModelView, ItemAdmin, UserAdmin, GroupAdmin,
+from .admin.views import (AdminModelView, ItemAdmin, UserAdmin, GroupAdmin, TokenAdmin,
                           NetworkAdmin, HostAdmin)
 from .main.views import bp as main
 from .users.views import bp as users
@@ -95,6 +95,7 @@ def create_app(config=None):
     admin.init_app(app)
     admin.add_view(GroupAdmin(models.Group, db.session))
     admin.add_view(UserAdmin(models.User, db.session))
+    admin.add_view(TokenAdmin(models.Token, db.session))
     admin.add_view(AdminModelView(models.Action, db.session))
     admin.add_view(AdminModelView(models.Manufacturer, db.session))
     admin.add_view(AdminModelView(models.Model, db.session))
diff --git a/app/models.py b/app/models.py
index d76880a..73c0589 100644
--- a/app/models.py
+++ b/app/models.py
@@ -21,7 +21,7 @@ from citext import CIText
 from flask import current_app
 from flask_login import UserMixin
 from wtforms import ValidationError
-from .extensions import db, login_manager, ldap_manager, jwt
+from .extensions import db, login_manager, ldap_manager
 from .plugins import FlaskUserPlugin
 from . import utils
 
@@ -41,16 +41,6 @@ def load_user(user_id):
     return User.query.get(int(user_id))
 
 
-@jwt.user_loader_callback_loader
-def user_loader_callback(identity):
-    """User loader callback for flask-jwt-extended
-
-    :param str identity: identity from the token (user_id)
-    :returns: corresponding user object or None
-    """
-    return User.query.get(int(identity))
-
-
 @ldap_manager.save_user
 def save_user(dn, username, data, memberships):
     """User saver for flask-ldap3-login
@@ -107,6 +97,7 @@ class User(db.Model, UserMixin):
     # See http://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html
     groups = association_proxy('grp', 'name',
                                creator=find_or_create_group)
+    tokens = db.relationship("Token", backref="user")
 
     def get_id(self):
         """Return the user id as unicode
@@ -133,6 +124,35 @@ class User(db.Model, UserMixin):
         return self.name
 
 
+class Token(db.Model):
+    """Table to store valid tokens"""
+    id = db.Column(db.Integer, primary_key=True)
+    jti = db.Column(postgresql.UUID, nullable=False)
+    token_type = db.Column(db.Text, nullable=False)
+    user_id = db.Column(db.Integer, db.ForeignKey('user_account.id'), nullable=False)
+    issued_at = db.Column(db.DateTime, nullable=False)
+    # expires can be set to None for tokens that never expire
+    expires = db.Column(db.DateTime)
+    description = db.Column(db.Text)
+
+    __table_args__ = (
+        sa.UniqueConstraint(jti, user_id),
+    )
+
+    def __str__(self):
+        return self.jti
+
+    def to_dict(self):
+        return {
+            'id': self.id,
+            'jti': self.jti,
+            'token_type': self.token_type,
+            'user_id': self.user_id,
+            'expires': self.expires,
+            'description': self.description,
+        }
+
+
 class QRCodeMixin:
     id = db.Column(db.Integer, primary_key=True)
     name = db.Column(CIText, nullable=False, unique=True)
diff --git a/app/settings.py b/app/settings.py
index cab395a..bf18063 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -22,6 +22,8 @@ MAIL_CREDENTIALS = None
 ADMIN_EMAILS = ['admin@example.com']
 EMAIL_SENDER = 'noreply@esss.se'
 
+JWT_BLACKLIST_ENABLED = True
+JWT_BLACKLIST_TOKEN_CHECKS = ['access', 'refresh']
 JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
 
 LDAP_HOST = 'esss.lu.se'
diff --git a/app/static/js/profile.js b/app/static/js/profile.js
new file mode 100644
index 0000000..9d2c67a
--- /dev/null
+++ b/app/static/js/profile.js
@@ -0,0 +1,20 @@
+$(document).ready(function() {
+
+  var $copyTokenBtn = $("#copyToken");
+  if( $copyTokenBtn.length ) {
+    // Instantiate the clipboard only if the copyToken button exists
+    var clipboard = new Clipboard($copyTokenBtn[0]);
+
+    // show tooltip "Copied!" on success
+    clipboard.on('success', function(e) {
+      $copyTokenBtn.tooltip('enable');
+      $copyTokenBtn.tooltip('show');
+    });
+
+    // disable tooltip when leaving button
+    $copyTokenBtn.on('mouseleave', function () {
+      $copyTokenBtn.tooltip('disable');
+    });
+  }
+
+});
diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html
index 70c69e1..a05fb01 100644
--- a/app/templates/users/profile.html
+++ b/app/templates/users/profile.html
@@ -1,11 +1,25 @@
-{% import "bootstrap/wtf.html" as wtf %}
 {% extends "base.html" %}
+{% from "_helpers.html" import render_field %}
 
 {% block title %}Profile - CSEntry{% endblock %}
 
 {% block main %}
-  <h2>{{ user.username }}</h2>
+  {% if generated_token %}
+  <div class="row">
+      <div class="input-group">
+        <div class="input-group-addon">
+          <button id="copyToken" type="button" class="btn btn-primary" data-clipboard-target="#generatedToken"
+            data-toggle="tooltip" data-placement="bottom" title="Copied!">
+            <span class="oi oi-clipboard" title="Copy to clipboard" aria-hidden="true"></span>
+          </button>
+        </div>
+        <input type="text" class="form-control" id="generatedToken" value="{{ generated_token }}" readonly>
+      </div>
+  </div>
+  <br>
+  {% endif %}
 
+  <h2>{{ user.username }}</h2>
   <dl>
     <dt>Name</dt>
     <dd>{{user.name}}</dd>
@@ -13,11 +27,57 @@
     <dd>{{user.email}}</dd>
     <dt>Groups</dt>
     <dd>{{ user.groups | join(', ') }}</dd>
-    {% if token %}
-    <dt>Token</dt>
-    <dd>{{ token }}</dd>
-    {% endif %}
   </dl>
 
-  <a class="btn btn-primary" href="{{ url_for('users.get_token') }}" role="button">Generate Token</a>
+  <h3>Personal access tokens</h3>
+  <p>Access tokens can be used to access the API</p>
+  {% if user.tokens %}
+  <table id="tokens_table" class="table table-hover table-sm">
+    <thead>
+      <tr>
+        <th></th>
+        <th>JWT id</th>
+        <th>Description</th>
+        <th>Token type</th>
+        <th>Issued at</th>
+        <th>Expires</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for token in user.tokens %}
+      <tr>
+        <td>
+          <form method="POST" action="/tokens/revoke">
+            <input id="token_id" name="token_id" type="hidden" value="{{ token.id }}">
+            <input id="jti" name="jti" type="hidden" value="{{ token.jti }}">
+            <button type="submit" class="btn btn-danger">
+              <span class="oi oi-trash" title="Revoke token" aria-hidden="true"></span>
+            </button>
+          </form>
+        </td>
+        <td>{{ token.jti }}</td>
+        <td>{{ token.description }}</td>
+        <td>{{ token.token_type }}</td>
+        <td>{{ token.issued_at }}</td>
+        <td>{{ token.expires }}</td>
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+  {% endif %}
+
+  <h4>Generate new access token</h4>
+  <form id="tokenForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.description) }}
+    <div class="form-group row">
+      <div class="col-sm-10">
+        <button type="submit" class="btn btn-primary">Generate token</button>
+      </div>
+    </div>
+  </form>
+{% endblock %}
+
+{% block csentry_scripts %}
+  <script src="{{ url_for('static', filename='js/profile.js') }}"></script>
 {% endblock %}
diff --git a/app/tokens.py b/app/tokens.py
new file mode 100644
index 0000000..3615d89
--- /dev/null
+++ b/app/tokens.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+"""
+app.api.tokens
+~~~~~~~~~~~~~~
+
+This module implements helper functions to manipulate JWT.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+import sqlalchemy as sa
+from datetime import datetime
+from flask import current_app
+from flask_jwt_extended import decode_token, create_access_token
+from .extensions import db, jwt
+from . import models, utils
+
+
+@jwt.user_loader_callback_loader
+def user_loader_callback(identity):
+    """User loader callback for flask-jwt-extended
+
+    :param str identity: identity from the token (user_id)
+    :returns: corresponding user object or None
+    """
+    return models.User.query.get(int(identity))
+
+
+@jwt.token_in_blacklist_loader
+def is_token_in_blacklist(decoded_token):
+    """Token blacklist loader for flask-jwt-extended
+
+    All created tokens are added to the database. If a token is not found
+    in the database, it is considered blacklisted / revoked.
+    """
+    jti = decoded_token['jti']
+    try:
+        models.Token.query.filter_by(jti=jti).one()
+    except sa.exc.NoResultFound:
+        return True
+    return False
+
+
+def generate_access_token(identity, fresh=False, expires_delta=None, description=None):
+    """Create a new access token and store it in the database"""
+    token = create_access_token(identity, fresh=fresh, expires_delta=expires_delta)
+    save_token(token, description=description)
+    return token
+
+
+def save_token(encoded_token, description=None):
+    """Add a new token to the database"""
+    identity_claim = current_app.config['JWT_IDENTITY_CLAIM']
+    decoded_token = decode_token(encoded_token)
+    jti = decoded_token['jti']
+    token_type = decoded_token['type']
+    user_id = int(decoded_token[identity_claim])
+    iat = datetime.fromtimestamp(decoded_token['iat'])
+    expires = datetime.fromtimestamp(decoded_token['exp'])
+    db_token = models.Token(
+        jti=jti,
+        token_type=token_type,
+        user_id=user_id,
+        issued_at=iat,
+        expires=expires,
+        description=description,
+    )
+    db.session.add(db_token)
+    db.session.commit()
+
+
+def revoke_token(token_id, user_id):
+    """Revoke the given token
+
+    Raises a CSEntryError if the token does not exist in the database
+    or if it doesn't belong to the given user
+    """
+    token = models.Token.query.get(token_id)
+    if token is None:
+        raise utils.CSEntryError(f'Could not find the token {token_id}', status_code=404)
+    if token.user_id != user_id:
+        raise utils.CSEntryError(f"Token {token_id} doesn't belong to user {user_id}", status_code=401)
+    db.session.delete(token)
+    db.session.commit()
+
+
+def prune_database():
+    """Delete tokens that have expired from the database"""
+    now = datetime.now()
+    expired = models.Token.query.filter(models.Token.expires < now).all()
+    for token in expired:
+        db.session.delete(token)
+    db.session.commit()
diff --git a/app/users/forms.py b/app/users/forms.py
new file mode 100644
index 0000000..06555e8
--- /dev/null
+++ b/app/users/forms.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+"""
+app.users.forms
+~~~~~~~~~~~~~~~
+
+This module defines the users forms.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+
+class TokenForm(FlaskForm):
+    description = StringField('description', validators=[validators.DataRequired()])
diff --git a/app/users/views.py b/app/users/views.py
index f4ab009..ea37c5e 100644
--- a/app/users/views.py
+++ b/app/users/views.py
@@ -9,10 +9,12 @@ This module implements the users blueprint.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
-from flask import Blueprint, render_template, request, redirect, url_for
+from flask import (Blueprint, render_template, request, redirect, url_for,
+                   flash, current_app, session)
 from flask_login import login_user, logout_user, login_required, current_user
 from flask_ldap3_login.forms import LDAPLoginForm
-from flask_jwt_extended import create_access_token
+from .forms import TokenForm
+from .. import tokens, utils
 
 bp = Blueprint('users', __name__)
 
@@ -33,14 +35,35 @@ def logout():
     return redirect(url_for('users.login'))
 
 
-@bp.route('/profile')
+@bp.route('/profile', methods=['GET', 'POST'])
 @login_required
 def profile():
-    return render_template('users/profile.html', user=current_user, token='')
+    # Try to get the generated token from the session
+    token = session.pop('generated_token', None)
+    form = TokenForm(request.form)
+    if form.validate_on_submit():
+        token = tokens.generate_access_token(identity=current_user.id,
+                                             description=form.description.data)
+        # Save token to the session to retrieve it after the redirect
+        session['generated_token'] = token
+        flash('Make sure to copy your new personal access token now. You won’t be able to see it again!', 'success')
+        return redirect(url_for('users.profile'))
+    return render_template('users/profile.html',
+                           form=form,
+                           user=current_user,
+                           generated_token=token)
 
 
-@bp.route('/token')
+@bp.route('/tokens/revoke', methods=['POST'])
 @login_required
-def get_token():
-    token = create_access_token(identity=current_user.id)
-    return render_template('users/profile.html', user=current_user, token=token)
+def revoke_token():
+    token_id = request.form['token_id']
+    jti = request.form['jti']
+    try:
+        tokens.revoke_token(token_id, current_user.id)
+    except utils.CSEntryError as e:
+        current_app.logger.warning(e)
+        flash(f'Could not revoke the token {jti}. Please contact an administrator.', 'error')
+    else:
+        flash(f'Token {jti} has been revoked', 'success')
+    return redirect(url_for('users.profile'))
-- 
GitLab