Skip to content

Commit 94e50e6

Browse files
authored
Merge pull request #359 from MerginMaps/fix_token_reuse
Fix shared context issues for security salts
2 parents 3d6d3c3 + c28a14f commit 94e50e6

File tree

9 files changed

+78
-41
lines changed

9 files changed

+78
-41
lines changed

.prod.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ SECRET_KEY=fixme
6161

6262
#BEARER_TOKEN_EXPIRATION=3600 * 12 # in seconds
6363

64+
#SECURITY_BEARER_SALT=NODEFAULT
65+
SECURITY_BEARER_SALT=fixme
66+
67+
#SECURITY_EMAIL_SALT=NODEFAULT
68+
SECURITY_EMAIL_SALT=fixme
69+
6470
#SECURITY_PASSWORD_SALT=NODEFAULT
6571
SECURITY_PASSWORD_SALT=fixme
6672

server/.test.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ GLOBAL_WORKSPACE='mergin'
2020
GLOBAL_STORAGE=104857600
2121
COLLECT_STATISTICS=0
2222
GEODIFF_WORKING_DIR=/tmp/geodiff
23+
SECURITY_BEARER_SALT='bearer'
24+
SECURITY_EMAIL_SALT='email'
25+
SECURITY_PASSWORD_SALT='password'

server/mergin/.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
GEODIFF_LOGGER_LEVEL="2"
22
# only for dev - should be overwritten in production
33
SECRET_KEY='top-secret'
4+
SECURITY_BEARER_SALT='top-secret'
5+
SECURITY_EMAIL_SALT='top-secret'
46
SECURITY_PASSWORD_SALT='top-secret'
57
MAIL_DEFAULT_SENDER=''
68
FLASK_DEBUG=0

server/mergin/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def load_user_from_header(header_val): # pylint: disable=W0613,W0612
211211
try:
212212
data = decode_token(
213213
app.app.config["SECRET_KEY"],
214+
app.app.config["SECURITY_BEARER_SALT"],
214215
header_val,
215216
app.app.config["BEARER_TOKEN_EXPIRATION"],
216217
)

server/mergin/auth/app.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,15 @@ def authenticate(login, password):
8080
return user
8181

8282

83-
def generate_confirmation_token(app, email):
83+
def generate_confirmation_token(app, email, salt):
8484
serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
85-
return serializer.dumps(email, salt=app.config["SECURITY_PASSWORD_SALT"])
85+
return serializer.dumps(email, salt=salt)
8686

8787

88-
def confirm_token(token, expiration=3600 * 24 * 3):
88+
def confirm_token(token, salt, expiration=3600):
8989
serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
9090
try:
91-
email = serializer.loads(
92-
token, salt=current_app.config["SECURITY_PASSWORD_SALT"], max_age=expiration
93-
)
91+
email = serializer.loads(token, salt=salt, max_age=expiration)
9492
except:
9593
return
9694
return email
@@ -103,7 +101,12 @@ def send_confirmation_email(app, user, url, template, header, **kwargs):
103101
"""
104102
from ..celery import send_email_async
105103

106-
token = generate_confirmation_token(app, user.email)
104+
salt = (
105+
app.config["SECURITY_EMAIL_SALT"]
106+
if url == "confirm-email"
107+
else app.config["SECURITY_PASSWORD_SALT"]
108+
)
109+
token = generate_confirmation_token(app, user.email, salt)
107110
confirm_url = f"{url}/{token}"
108111
html = render_template(
109112
template, subject=header, confirm_url=confirm_url, user=user, **kwargs

server/mergin/auth/bearer.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
from flask.sessions import TaggedJSONSerializer
88

99

10-
def decode_token(secret_key, token, max_age=None):
11-
salt = "bearer-session"
10+
def decode_token(secret_key, salt, token, max_age=None):
1211
serializer = TaggedJSONSerializer()
1312
signer_kwargs = {"key_derivation": "hmac", "digest_method": hashlib.sha1}
1413
s = URLSafeTimedSerializer(
@@ -17,8 +16,7 @@ def decode_token(secret_key, token, max_age=None):
1716
return s.loads(token, max_age=max_age)
1817

1918

20-
def encode_token(secret_key, data):
21-
salt = "bearer-session"
19+
def encode_token(secret_key, salt, data):
2220
serializer = TaggedJSONSerializer()
2321
signer_kwargs = {"key_derivation": "hmac", "digest_method": hashlib.sha1}
2422
s = URLSafeTimedSerializer(

server/mergin/auth/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77

88
class Configuration(object):
9+
SECURITY_BEARER_SALT = config("SECURITY_BEARER_SALT")
10+
SECURITY_EMAIL_SALT = config("SECURITY_EMAIL_SALT")
911
SECURITY_PASSWORD_SALT = config("SECURITY_PASSWORD_SALT")
1012
BEARER_TOKEN_EXPIRATION = config(
1113
"BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int

server/mergin/auth/controller.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
from ..sync.utils import files_size
4040

4141

42+
EMAIL_CONFIRMATION_EXPIRATION = 12 * 3600
43+
44+
4245
# public endpoints
4346
def user_profile(user, return_all=True):
4447
"""Return user profile in json format
@@ -143,7 +146,11 @@ def login_public(): # noqa: E501
143146
"email": user.email,
144147
"expire": str(expire),
145148
}
146-
token = encode_token(current_app.config["SECRET_KEY"], token_data)
149+
token = encode_token(
150+
current_app.config["SECRET_KEY"],
151+
current_app.config["SECURITY_BEARER_SALT"],
152+
token_data,
153+
)
147154

148155
data = user_profile(user)
149156
data["session"] = {"token": token, "expire": expire}
@@ -297,7 +304,7 @@ def password_reset(): # pylint: disable=W0613,W0612
297304

298305

299306
def confirm_new_password(token): # pylint: disable=W0613,W0612
300-
email = confirm_token(token)
307+
email = confirm_token(token, salt=current_app.config["SECURITY_PASSWORD_SALT"])
301308
if not email:
302309
abort(400, "Invalid token")
303310

@@ -315,7 +322,11 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612
315322

316323

317324
def confirm_email(token): # pylint: disable=W0613,W0612
318-
email = confirm_token(token)
325+
email = confirm_token(
326+
token,
327+
expiration=EMAIL_CONFIRMATION_EXPIRATION,
328+
salt=current_app.config["SECURITY_EMAIL_SALT"],
329+
)
319330
if not email:
320331
abort(400, "Invalid token")
321332

@@ -375,7 +386,9 @@ def register_user(): # pylint: disable=W0613,W0612
375386
if form.validate():
376387
user = User.create(form.username.data, form.email.data, form.password.data)
377388
user_created.send(user, source="admin")
378-
token = generate_confirmation_token(current_app, user.email)
389+
token = generate_confirmation_token(
390+
current_app, user.email, current_app.config["SECURITY_EMAIL_SALT"]
391+
)
379392
confirm_url = f"confirm-email/{token}"
380393
html = render_template(
381394
"email/user_created.html",

server/mergin/tests/test_auth.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from datetime import datetime, timedelta
66
import os
7+
import time
78
import pytest
89
import json
910
from flask import url_for
@@ -12,7 +13,7 @@
1213
from unittest.mock import patch
1314

1415
from mergin.tests import test_workspace
15-
16+
from ..auth.app import generate_confirmation_token, confirm_token
1617
from ..auth.models import User, UserProfile, LoginHistory
1718
from ..auth.tasks import anonymize_removed_users
1819
from ..app import db
@@ -152,26 +153,20 @@ def test_user_register(client, username, email, pwd, expected):
152153

153154

154155
def test_confirm_email(app, client):
155-
serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
156-
token = serializer.dumps(
157-
"mergin@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"]
158-
)
159-
resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token))
160-
assert resp.status_code == 200
161-
162156
user = User.query.filter_by(username="mergin").first()
163-
# tests with old registered user
157+
token = generate_confirmation_token(
158+
app, user.email, app.config["SECURITY_EMAIL_SALT"]
159+
)
164160
user.verified_email = False
165-
user.registration_date = datetime.utcnow() - timedelta(days=1)
166161
db.session.commit()
167-
resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token))
168-
assert resp.status_code == 200
169162

170-
# try again with freshly registered user
171-
user.verified_email = False
172-
user.registration_date = datetime.utcnow()
173-
db.session.add(user)
174-
db.session.commit()
163+
# verify token can't be used in different context
164+
resp = client.post(
165+
url_for("/.mergin_auth_controller_confirm_new_password", token=token),
166+
json={"password": "ilovemergin#0", "confirm": "ilovemergin#0"},
167+
)
168+
assert resp.status_code == 400
169+
175170
resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token))
176171
assert resp.status_code == 200
177172

@@ -187,21 +182,35 @@ def test_confirm_email(app, client):
187182
resp = client.post(
188183
url_for(
189184
"/.mergin_auth_controller_confirm_email",
190-
token=serializer.dumps(
191-
"tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"]
185+
token=generate_confirmation_token(
186+
app, "tests@mergin.com", app.config["SECURITY_EMAIL_SALT"]
192187
),
193188
)
194189
)
195190
assert resp.status_code == 404
196191

192+
# test expired token
193+
token = generate_confirmation_token(
194+
app, user.email, app.config["SECURITY_EMAIL_SALT"]
195+
)
196+
time.sleep(2)
197+
assert not confirm_token(
198+
token=token, expiration=1, salt=app.config["SECURITY_EMAIL_SALT"]
199+
)
200+
197201

198202
def test_confirm_password(app, client):
199-
serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
200-
token = serializer.dumps(
201-
"mergin@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"]
203+
user = User.query.filter_by(username="mergin").first()
204+
token = generate_confirmation_token(
205+
app, user.email, app.config["SECURITY_PASSWORD_SALT"]
202206
)
203207

204208
form_data = {"password": "ilovemergin#0", "confirm": "ilovemergin#0"}
209+
210+
# verify token can't be used in different context
211+
resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token))
212+
assert resp.status_code == 400
213+
205214
resp = client.post(
206215
url_for("/.mergin_auth_controller_confirm_new_password", token=token),
207216
data=json.dumps(form_data),
@@ -221,8 +230,8 @@ def test_confirm_password(app, client):
221230
resp = client.post(
222231
url_for(
223232
"/.mergin_auth_controller_confirm_new_password",
224-
token=serializer.dumps(
225-
"tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"]
233+
token=generate_confirmation_token(
234+
app, "tests@mergin.com", app.config["SECURITY_PASSWORD_SALT"]
226235
),
227236
),
228237
data=json.dumps(form_data),
@@ -240,8 +249,8 @@ def test_confirm_password(app, client):
240249
resp = client.post(
241250
url_for(
242251
"/.mergin_auth_controller_confirm_new_password",
243-
token=serializer.dumps(
244-
"tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"]
252+
token=generate_confirmation_token(
253+
app, "tests@mergin.com", app.config["SECURITY_PASSWORD_SALT"]
245254
),
246255
)
247256
)

0 commit comments

Comments
 (0)