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