Skip to content

Commit

Permalink
feat: Implement Refresh Tokens (fossasia#6204)
Browse files Browse the repository at this point in the history
* feat: Implement blacklisting and fresh token

* Reduce expiry for refresh based token
  • Loading branch information
iamareebjamal authored and mrsaicharan1 committed Aug 9, 2019
1 parent 2753e7a commit ec03c5a
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 15 deletions.
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 (
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 ###

0 comments on commit ec03c5a

Please sign in to comment.