1

At my current job, I happened to be a part of a backend team, which is creating an API. The API should be then served to JavaScript application and needs to be quite fast (100 ms or so). However, it is not.

After some profiling, we figured out that it is the token authentication in Flask-security, which is holding us back (please see the MWE).

MWE

import flask
from flask_security import Security, SQLAlchemyUserDatastore, UserMixin, RoleMixin, auth_required
from flask_sqlalchemy import SQLAlchemy

app = flask.Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/database.sqlite3'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['WTF_CSRF_ENABLED'] = False
app.config['SECURITY_TOKEN_AUTHENTICATION_HEADER'] = 'Authorization'
app.config['SECURITY_PASSWORD_HASH'] = 'pbkdf2_sha512'
app.config['SECURITY_PASSWORD_SALT'] = b'secret'
app.config['SECRET_KEY'] = "super_secret"

db = SQLAlchemy(app)

roles_users = db.Table('roles_users',
                       db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
                       db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))


class Role(db.Model, RoleMixin):
    id = db.Column(db.Integer(), primary_key=True)
    name = db.Column(db.String(80), unique=True)
    description = db.Column(db.String(255))


class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(255), unique=True)
    password = db.Column(db.String(255))
    active = db.Column(db.Boolean())
    confirmed_at = db.Column(db.DateTime())
    roles = db.relationship('Role', secondary=roles_users,
                            backref=db.backref('users', lazy='dynamic'))


user_datastore = SQLAlchemyUserDatastore(db, User, Role)

# Setup Flask-Security
security = Security(app, user_datastore)

db.drop_all()
db.create_all()

admin_role = Role(**{'name': 'admin', 'description': 'Admin role'})
db.session.add(admin_role)
db.session.commit()

user_datastore.create_user(email='test@example.com', password='test', active=True, roles=[Role.query.first()])
db.session.commit()


@app.route('/')
@auth_required('basic', 'token')
def hello():
    return flask.jsonify({'hello': 'world'})


if __name__ == '__main__':
    app.run(debug=True)

Basic authentication timing

The timings are perfect (bellow 100 ms) but that is not the way we should do it.

time curl http://127.0.0.1:5000/ -u "test@example.com:test"
{
  "hello": "world"
}


real    0m0.076s
user    0m0.008s
sys     0m0.006s

Token authentication timing

Getting the token is OK.

curl -H "Content-Type: application/json" -X POST -d '{"email":"test@example.com","password":"test"}' http://127.0.0.1:5000/login

{
    "meta": {
        "code": 200
    }, 
    "response": {
        "user": {
          "authentication_token": "WyIxIiwiJDUkcm91bmRzPTUzNTAwMCRFRUpLRFNONlB2L1hzL2lRJDhMWFZvZlpLMmVoa1BVdWtpRlhUR1lvNEJ3T3FjS3dKMVhVWGlOczRwZDMiXQ.DOLjcQ.oBrT4gr1m49rISyxhaj9Lxu1VNk", 
          "id": "1"
        }
    }
}

But than the request is terribly slow. The timings are 20 times slower.

time curl "http://127.0.0.1:5000/?auth_token=WyIxIiwiJDUkcm91bmRzPTUzNTAwMCRFRUpLRFNONlB2L1hzL2lRJDhMWFZvZlpLMmVoa1BVdWtpRlhUR1lvNEJ3T3FjS3dKMVhVWGlOczRwZDMiXQ.DOLjcQ.oBrT4gr1m49rISyxhaj9Lxu1VNk"
{
  "hello": "world"
}

real    0m2.371s
user    0m0.005s
sys     0m0.006s

What with that???

I know that Flask-security wraps together several other flask security packages (Flask-login, Flask-WTF, ...).

  1. Do you know what could be the cause? (is it Flask-security or Flask-login or something deeper?)
  2. It seems that the hashing algorithm, which is slow, is running for every request. However, it might not be necessary to do it every time. It should be enough, to only store the token and check if the incoming token is the same as the stored one. Is there a way to do it like that (either with Flask-security or not)?
  3. Can I set the app (app.config) a different way to make it faster (still using the token auth.)?
  4. Is there a workaround (still using Flask-security)?
  5. Shall I write it myself? Is it the Flask-security holding us back?
  6. Anyone have a clue about this ?

I have cross-posted this as an issue on GitHub.

KrysotL
  • 94
  • 9
  • Good question, but please don't mention deadlines here - your audience is almost entirely volunteers, and they are here at their leisure. – halfer Nov 07 '17 at 14:19
  • @POLOSTutorials: if you see a declaration of cross-posting in a question, please do not remove it. Cross-posting may be seen as somewhat impatient, but undeclared cross-posting is much worse, and the OP may receive downvotes for that, even though the first version correctly owned up to the cross-posting. – halfer Nov 07 '17 at 14:22
  • KrysotL: I can't see the cross-posted question, but please make sure that you link back to here, to avoid wasting the time of a helpful person in the future, who may inadvertently help you on something that is already solved elsewhere. – halfer Nov 07 '17 at 14:24
  • 1
    @halfer Thank you for the edits. PythonistaCafe is a private forum, so I removed it. However, I added cross-post to the github issue I opened. – KrysotL Nov 07 '17 at 15:29
  • @KrysotL any updates on this? Did you manage to work around it somehow? – Carl Dec 12 '17 at 10:34
  • 1
    Well, we are moving away from `flask-security` (more details in github [issue comment](https://github.com/mattupstate/flask-security/issues/731#issuecomment-354956309)) – KrysotL Jan 03 '18 at 12:55

3 Answers3

2

The authentication tokens are signed - using itsdangerous and your SECRET_KEY. Thus Flask-Security (and you) can be assured that the contents haven't been tampered with. Verifying this is fast. Inside the token are the user_id and a hashed version of the (already hashed) user's password. The user_id can't be considered unique since in some DBs, primary key values can be reused - so Flask-Security needs some uniquifier to be able to be certain that the token corresponded to the correct user. It chose the user's password. Now, you don't want to return the hashed user's password in the token (remember - tokens are signed, not encrypted) - so FS chose to once again hash the (already hashed) password. To validate the token, it verifies it was signed by us, then pulls out the user_id and hashed-password and compare that with the password stored in the DB. Password hashing, by design, is slow - and that is the cause of the slow requests. Along the lines of @acidjunk, https://github.com/jwag956/flask-security (my Flask-Security fork) has implemented a solution to this by simply adding a new field into the Usermodel that can act as a uniquifier (default is to use a uuid). This results in a simple equality check rather than a hash.

jwag
  • 662
  • 5
  • 6
  • When you're still using the unmaintained Flask-Security: please consider using this fork. It solves a couple of bugs, is actively maintained, has better documentation and adds new features (like 2FA) – acidjunk Jul 01 '20 at 15:42
1

For what it's worth an actively (as of November 2019) developed fork of Flask Security claims to have solved this problem with release 3.3.0:

https://github.com/jwag956/flask-security/blob/master/CHANGES.rst#version-330

Puchatek
  • 1,477
  • 3
  • 15
  • 33
0

I've run into this as well. It's the hashing and as far as I know part of the Flask-Security philosophy. When you change the password the token will be invalid immediately: I'm still not sure if this a good requirement/feature but I already built a whole eco system around it. As I have a SPA that does a lot of requests I couldn't live with the 1 - 3 seconds extra on every request: I also didn't want to use a less secure hashing method for the login.

So: I added a second token in the DB that's valid for 30 minutes and can be validated much quicker.

Scenario: User logs in with normal Flask-Security functionality. An extra REST endpoint is available that is only accesible with the slow token wil return a new "Quick-Authentication-Token" and store a MD5() presentation of it in the Database, and set a timestamp in a second column. I use this "Quick-Authentication-Token" for all app level REST requests, I just added a new decorator for it.

All user stuff like, changing password, updating preferences etc, is still handled by the normal login. REST requests with less security impact are authenticated via the Quick-Authentication-Token. After 30 minutes the SPA will re-retrieve a new Quick-Authentication-Token with the original Authentication-Token.

I hope it's clear. A proof of concept can be found here: https://github.com/acidjunk/improviser/blob/master/improviser/security.py

acidjunk
  • 1,751
  • 18
  • 24