Wrong error: SECURITY_PASSWORD_SALT must not be None when SECURITY_PASSWORD_HASH set to "plaintext"
See original GitHub issueThe auth token retrieval does not work in a testing environment with SECURITY_PASSWORD_HASH being set to ‘plaintext’. Using ‘plaintext’ makes sense for running unit tests (the crypt routines otherwise may significantly affect the duration it takes to run the tests).
Consider this small application:
from flask import Flask, render_template, g
from flask.ext.sqlalchemy import SQLAlchemy
import flask.ext.security
from flask.ext.security import UserMixin, RoleMixin
from flask.ext.security.registerable import register_user
auth_required = flask.ext.security.auth_token_required
app = Flask(__name__)
app.config['DEBUG'] = True
app.config['SECURITY_SEND_REGISTER_EMAIL'] = False
app.config['SECRET_KEY'] = 'a'
app.config['WTF_CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite://"
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 = flask.ext.security.SQLAlchemyUserDatastore(db, User, Role)
security = flask.ext.security.Security(app, user_datastore)
@app.route('/createtestuser')
def create_user():
db.drop_all()
db.create_all()
register_user(email="mail1", password="testpassword1")
return "OK"
app.run(debug=True, host="0.0.0.0")
Create a test user:
$ curl -i http://localhost:5000/createtestuser HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 2
Set-Cookie: session=eyJfaWQiOnsiIGIiOiJNR0ZtTVRjd1lqVTRZMkppTjJFMk0yTm1NMll4TkRKbFltVXpNR0l5WW1FPSJ9fQ.CCu0Ww.dnadUzB93ML3oZt2EdrpbFS5bnA; HttpOnly; Path=/
Server: Werkzeug/0.10.4 Python/2.7.3
Date: Wed, 06 May 2015 14:19:07 GMT
OK
Attempt to retrieve auth token:
$ curl -i --data '{"password": "testpassword1", "email": "mail1"}' http://localhost:5000/login -H "Content-Type: application/json"
Yields the following traceback:
Traceback (most recent call last):
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1836, in __call__
return self.wsgi_app(environ, start_response)
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1820, in wsgi_app
response = self.make_response(self.handle_exception(e))
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1403, in handle_exception
reraise(exc_type, exc_value, tb)
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1817, in wsgi_app
response = self.full_dispatch_request()
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1477, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1381, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1475, in full_dispatch_request
rv = self.dispatch_request()
File "/xxx/venv/lib/python2.7/site-packages/flask/app.py", line 1461, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/xxx/venv/lib/python2.7/site-packages/flask_security/decorators.py", line 205, in wrapper
return f(*args, **kwargs)
File "/xxx/venv/lib/python2.7/site-packages/flask_security/views.py", line 79, in login
if form.validate_on_submit():
File "/xxx/venv/lib/python2.7/site-packages/flask_wtf/form.py", line 166, in validate_on_submit
return self.is_submitted() and self.validate()
File "/xxx/venv/lib/python2.7/site-packages/flask_security/forms.py", line 238, in validate
if not verify_and_update_password(self.password.data, self.user):
File "/xxx/venv/lib/python2.7/site-packages/flask_security/utils.py", line 137, in verify_and_update_password
password = get_hmac(password)
File "/xxx/venv/lib/python2.7/site-packages/flask_security/utils.py", line 110, in get_hmac
'set to "%s"' % _security.password_hash)
RuntimeError: The configuration value `SECURITY_PASSWORD_SALT` must not be None when the value of `SECURITY_PASSWORD_HASH` is set to "plaintext"
This error message is contradictory to the documentation which clearly states that SECURITY_PASSWORD_SALT “Specifies the HMAC salt. This is only used if the password hash type is set to something other than plain text. Defaults to None.”
So I set a salt which theoretically could work with the plaintext hashing method:
app.config['SECURITY_PASSWORD_SALT'] = b"xxx"
(I still leave the default for SECURITY_PASSWORD_HASH which is ‘plaintext’). The traceback disappears, but now the password validation is broken:
$ curl -i --data '{"password": "testpassword1", "email": "mail1"}' http://localhost:5000/login -H "Content-Type: application/json"
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 134
Set-Cookie: session=eyJfaWQiOnsiIGIiOiJNR0ZtTVRjd1lqVTRZMkppTjJFMk0yTm1NMll4TkRKbFltVXpNR0l5WW1FPSJ9fQ.CCu1kA.pNVcVQFzFV-tfK2KAPj2pCMivko; HttpOnly; Path=/
Server: Werkzeug/0.10.4 Python/2.7.3
Date: Wed, 06 May 2015 14:24:16 GMT
{
"meta": {
"code": 400
},
"response": {
"errors": {
"password": [
"Invalid password"
]
}
}
}
That is, the password validation method does not properly account for this scenario, right?
So I set a hashing method different from plaintext:
app.config['SECURITY_PASSWORD_HASH'] = "sha512_crypt"
Now the password validation works and I retrieve an auth token. But, really, this should work with the default settings (no hash method, no salt), or there should be an improved error message.
As a side note: As you can see above, the response always contains a Set-Cookie: session=eyJfaWQiOnsiIGIi......
header. Why is that? Is that proper behavior? Does it come from the Flask-Login extension? Its documentation states that it only sets a cookie when using remember=True
upon executing the login routine. How can I make the minimal app shown above make not set a cookie upon each request? Should I open another issue for that?
Issue Analytics
- State:
- Created 8 years ago
- Comments:5
The reason that you have having these errors is because the password that you are using is misidentified by passlib. This can happen when your password choice happens to start with one of the strings that passlib uses to identifying hashing schemes e.g. $5$, $6$, $2$, $2a$, $2b$, $2x$, $2y$.
This causes Flask-Security to think that your password was saved using a hash and not plaintext.
As you can see Flask now thinks that the saved user.password is encoded with a hash (even if it’s not) and therefore that it was salted (even if it’s not). So in order to check your input password against the user.password in the system, it salts your input password. That is why it throws the “no salt” error. So you are correct in saying that the error message is ‘incorrect’ but Flask-Security /is/ legitimately trying to salt your password.
You might ask: So given passlib’s proclivity for false positives, why do we use it to identify password hashes at all? Isn’t the the SECURITY_PASSWORD_HASH flag enough to tell Flask-Security whether user.password is encoded in plaintext? Well technically yes, but the reason that passlib’s identify function is used is so that Flask-Security can gracefully deal with hashing algorithm changes i.e. if you change SECURITY_PASSWORD_HASH between runs, this identify call allows Flask-Security to load passwords using older hashing schemes and rehash it using your new hashing scheme.
This bug is more than just the error message, if you had a salt defined then Flask-Security would mistakenly salt your input password and it passlib would also misidentify the stored password as having been hashed rather than plaintext which may lead to a decoding error in passlib.
How can this be fixed? We can’t just change the error message or suppress the error for the reason given above. The source of the error is in a fundamental ambiguity in the way that password hashes are done. Because the hashing method is not stored with the password, after a hash has been written it’s impossible to know whether it was a plaintext password that looks like a hash or actually a hash. The easiest method imo is to output some kind of identifying string in front of plaintext passwords e.g. $plaintext$. But this could potentially be breaking for old code running on databases that don’t have the identifying string (which might be avoidable with some backwards-compatibility checks though I haven’t thought it through completely yet).
An alternative is to just make SECURITY_PASSWORD_HASH=plaintext indicate a second plaintext check. That is to say when config is set to plaintext, Flask-Security would put a try-catch around verify_and_update_password and in case of failure (possible misidentification), would just do a string match between the input password and the stored password without using passlib. This is a potential security flaw since someone with access to the hashed user.password would be able to unlock the account without the extra layer of security provided by the salt. But since plaintext mode is obviously insecure to begin with, this might not be horrible.
It might also be desirable to just store the password hash type in the db rather than relying on the identifier. But this also breaks backwards compatibility in a less comfortable way since it also forces a change to the db schema.
There are probably other workarounds that I haven’t thought of yet.
SECURITY_PASSWORD_SALT
closed via ff0ec74df0f36670e4a8216e1345b25810be2089encrypt_password
must be used in app context in order to get correct configuration (wontfix)