Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement Refresh Tokens #6204

Merged
merged 7 commits into from
Aug 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from app.api.helpers.cache import cache
from werkzeug.middleware.profiler import ProfilerMiddleware
from app.views import BlueprintsManager
from app.api.helpers.auth import AuthManager
from app.api.helpers.auth import AuthManager, is_token_blacklisted
from app.api.helpers.scheduled_jobs import send_after_event_mail, send_event_fee_notification, \
send_event_fee_notification_followup, change_session_state_on_event_completion, \
expire_pending_tickets, send_monthly_event_invoice, event_invoices_mark_due
Expand Down Expand Up @@ -104,9 +104,16 @@ def create_app():
# set up jwt
app.config['JWT_HEADER_TYPE'] = 'JWT'
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(days=1)
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=365)
app.config['JWT_ERROR_MESSAGE_KEY'] = 'error'
app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'headers']
app.config['JWT_REFRESH_COOKIE_PATH'] = '/v1/auth/token/refresh'
app.config['JWT_SESSION_COOKIE'] = False
app.config['JWT_BLACKLIST_ENABLED'] = True
app.config['JWT_BLACKLIST_TOKEN_CHECKS'] = ['refresh']
_jwt = JWTManager(app)
_jwt.user_loader_callback_loader(jwt_user_loader)
_jwt.token_in_blacklist_loader(is_token_blacklisted)

# setup celery
app.config['CELERY_BROKER_URL'] = app.config['REDIS_URL']
Expand Down
78 changes: 66 additions & 12 deletions app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import base64
import base64
import logging
import random
import string
from datetime import timedelta
from functools import wraps

import requests
from flask import request, jsonify, make_response, Blueprint, send_file
from flask_jwt_extended import jwt_required, current_user, create_access_token
from flask_jwt_extended import (
iamareebjamal marked this conversation as resolved.
Show resolved Hide resolved
jwt_required, jwt_refresh_token_required,
fresh_jwt_required, unset_jwt_cookies,
current_user, create_access_token,
create_refresh_token, set_refresh_cookies,
get_jwt_identity)
from flask_limiter.util import get_remote_address
from healthcheck import EnvironmentDump
from flask_rest_jsonapi.exceptions import ObjectNotFound
Expand All @@ -16,7 +21,7 @@
from app import get_settings
from app import limiter
from app.api.helpers.db import save_to_db, get_count, safe_query
from app.api.helpers.auth import AuthManager
from app.api.helpers.auth import AuthManager, blacklist_token
from app.api.helpers.jwt import jwt_authenticate
from app.api.helpers.errors import ForbiddenError, UnprocessableEntityError, NotFoundError, BadRequestError
from app.api.helpers.files import make_frontend_url
Expand Down Expand Up @@ -45,9 +50,7 @@
auth_routes = Blueprint('auth', __name__, url_prefix='/v1/auth')


@authorised_blueprint.route('/auth/session', methods=['POST'])
@auth_routes.route('/login', methods=['POST'])
def login():
def authenticate(allow_refresh_token=False, existing_identity=None):
data = request.get_json()
username = data.get('email', data.get('username'))
password = data.get('password')
Expand All @@ -57,13 +60,64 @@ def login():
return jsonify(error='username or password missing'), 400

identity = jwt_authenticate(username, password)

if identity:
access_token = create_access_token(identity.id, fresh=True)
return jsonify(access_token=access_token)
else:
if not identity or (existing_identity and identity != existing_identity):
# For fresh login, credentials should match existing user
return jsonify(error='Invalid Credentials'), 401

remember_me = data.get('remember-me')
include_in_response = data.get('include-in-response')
add_refresh_token = allow_refresh_token and remember_me

expiry_time = timedelta(minutes=90) if add_refresh_token else None
access_token = create_access_token(identity.id, fresh=True, expires_delta=expiry_time)
response_data = {'access_token': access_token}

if add_refresh_token:
refresh_token = create_refresh_token(identity.id)
if include_in_response:
response_data['refresh_token'] = refresh_token

response = jsonify(response_data)

if add_refresh_token and not include_in_response:
set_refresh_cookies(response, refresh_token)

return response


@authorised_blueprint.route('/auth/session', methods=['POST'])
@auth_routes.route('/login', methods=['POST'])
def login():
return authenticate(allow_refresh_token=True)


@auth_routes.route('/fresh-login', methods=['POST'])
@jwt_required
def fresh_login():
return authenticate(existing_identity=current_user)


@auth_routes.route('/token/refresh', methods=['POST'])
@jwt_refresh_token_required
def refresh_token():
current_user = get_jwt_identity()
new_token = create_access_token(identity=current_user, fresh=False)
return jsonify({'access_token': new_token})


@auth_routes.route('/logout', methods=['POST'])
def logout():
response = jsonify({'success': True})
unset_jwt_cookies(response)
return response


@auth_routes.route('/blacklist', methods=['POST'])
@jwt_required
def blacklist_token_rquest():
blacklist_token(current_user)
return jsonify({'success': True})


@auth_routes.route('/oauth/<provider>', methods=['GET'])
def redirect_uri(provider):
Expand Down Expand Up @@ -290,7 +344,7 @@ def reset_password_patch():


@auth_routes.route('/change-password', methods=['POST'])
@jwt_required
@fresh_jwt_required
def change_password():
old_password = request.json['data']['old-password']
new_password = request.json['data']['new-password']
Expand Down
22 changes: 22 additions & 0 deletions app/api/helpers/auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import datetime

import pytz
import flask_login as login
from flask_login import current_user

from app.models import db
from app.models.user import User
from app.models.user_token_blacklist import UserTokenBlackListTime


class AuthManager:
Expand Down Expand Up @@ -40,3 +44,21 @@ def check_auth_admin(username, password):
if user and user.is_correct_password(password) and user.is_admin:
return True
return False


def blacklist_token(user):
blacklist_time = UserTokenBlackListTime.query.filter_by(user_id=user.id).first()
if blacklist_time:
blacklist_time.blacklisted_at = datetime.datetime.now(pytz.utc)
else:
blacklist_time = UserTokenBlackListTime(user.id)

db.session.add(blacklist_time)
db.session.commit()


def is_token_blacklisted(token):
blacklist_time = UserTokenBlackListTime.query.filter_by(user_id=token['identity']).first()
if not blacklist_time:
return False
return token['iat'] < blacklist_time.blacklisted_at.timestamp()
3 changes: 2 additions & 1 deletion app/api/users.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import base64

from flask import Blueprint, request, jsonify, abort, make_response
from flask_jwt_extended import current_user
from flask_jwt_extended import current_user, verify_fresh_jwt_in_request
from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship
from sqlalchemy.orm.exc import NoResultFound
import urllib.error
Expand Down Expand Up @@ -241,6 +241,7 @@ def before_update_object(self, user, data, view_kwargs):
try:
db.session.query(User).filter_by(email=users_email).one()
except NoResultFound:
verify_fresh_jwt_in_request()
view_kwargs['email_changed'] = user.email
else:
raise ConflictException({'pointer': '/data/attributes/email'}, "Email already exists")
Expand Down
23 changes: 23 additions & 0 deletions app/models/user_token_blacklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from sqlalchemy.sql import func as sql_func

from app.models import db


class UserTokenBlackListTime(db.Model):
"""user token blacklist time model class"""
__tablename__ = 'user_token_blacklist_time'
__table_args__ = (db.UniqueConstraint('user_id', name='user_blacklist_time_uc'),)

id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime(timezone=True), default=sql_func.now(), nullable=False)
blacklisted_at = db.Column(db.DateTime(timezone=True), default=sql_func.now(), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
user = db.relationship("User", backref="token_blacklist_times", foreign_keys=[user_id])

def __init__(self, user_id=None, created_at=None, blacklisted_at=None):
self.user_id = user_id
self.created_at = created_at
self.blacklisted_at = blacklisted_at

def __str__(self):
return '<TokenBlackListTime User %s blacklisted at %s>' % (self.user, self.blacklisted_at)
2 changes: 1 addition & 1 deletion migrations/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
file_template = rev-%%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d:%%(minute).2d:%%(second).2d-%%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add user token blacklist time table
Revision ID: 4925dd5fd720
Revises: 2504915ffd08
Create Date: 2019-08-03 05:18:10.804364
"""

from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils


# revision identifiers, used by Alembic.
revision = '4925dd5fd720'
down_revision = '2504915ffd08'


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_token_blacklist_time',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default='now', nullable=False),
sa.Column('blacklisted_at', sa.DateTime(timezone=True), server_default='now', nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', name='user_blacklist_time_uc')
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_token_blacklist_time')
# ### end Alembic commands ###