From 1e5060e723b04fa6af746cb98ef521e9e186bdbc Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Mon, 20 Jan 2025 16:28:15 +0100 Subject: [PATCH 01/31] Fix shared context issues for security salts --- .prod.env | 2 ++ server/.test.env | 3 ++ server/mergin/.env | 2 ++ server/mergin/app.py | 1 + server/mergin/auth/app.py | 17 +++++---- server/mergin/auth/bearer.py | 6 ++-- server/mergin/auth/config.py | 2 ++ server/mergin/auth/controller.py | 16 ++++++--- server/mergin/tests/test_auth.py | 61 ++++++++++++++++++-------------- 9 files changed, 69 insertions(+), 41 deletions(-) diff --git a/.prod.env b/.prod.env index 8f52df39..68f251e0 100644 --- a/.prod.env +++ b/.prod.env @@ -62,6 +62,8 @@ SECRET_KEY=fixme #BEARER_TOKEN_EXPIRATION=3600 * 12 # in seconds #SECURITY_PASSWORD_SALT=NODEFAULT +SECURITY_BEARER_SALT=fixme +SECURITY_EMAIL_SALT=fixme SECURITY_PASSWORD_SALT=fixme #WTF_CSRF_ENABLED=True diff --git a/server/.test.env b/server/.test.env index d83920b8..908db5b9 100644 --- a/server/.test.env +++ b/server/.test.env @@ -20,3 +20,6 @@ GLOBAL_WORKSPACE='mergin' GLOBAL_STORAGE=104857600 COLLECT_STATISTICS=0 GEODIFF_WORKING_DIR=/tmp/geodiff +SECURITY_BEARER_SALT='bearer' +SECURITY_EMAIL_SALT='email' +SECURITY_PASSWORD_SALT='password' diff --git a/server/mergin/.env b/server/mergin/.env index db417d2e..0ff3dc42 100644 --- a/server/mergin/.env +++ b/server/mergin/.env @@ -1,6 +1,8 @@ GEODIFF_LOGGER_LEVEL="2" # only for dev - should be overwritten in production SECRET_KEY='top-secret' +SECURITY_BEARER_SALT='top-secret' +SECURITY_EMAIL_SALT='top-secret' SECURITY_PASSWORD_SALT='top-secret' MAIL_DEFAULT_SENDER='' FLASK_DEBUG=0 diff --git a/server/mergin/app.py b/server/mergin/app.py index 86c72d74..24b1ebc9 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -211,6 +211,7 @@ def load_user_from_header(header_val): # pylint: disable=W0613,W0612 try: data = decode_token( app.app.config["SECRET_KEY"], + app.app.config["SECURITY_BEARER_SALT"], header_val, app.app.config["BEARER_TOKEN_EXPIRATION"], ) diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index cfba8d98..7d3d8e29 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -80,17 +80,15 @@ def authenticate(login, password): return user -def generate_confirmation_token(app, email): +def generate_confirmation_token(app, email, salt): serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) - return serializer.dumps(email, salt=app.config["SECURITY_PASSWORD_SALT"]) + return serializer.dumps(email, salt=salt) -def confirm_token(token, expiration=3600 * 24 * 3): +def confirm_token(token, salt, expiration=3600): serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) try: - email = serializer.loads( - token, salt=current_app.config["SECURITY_PASSWORD_SALT"], max_age=expiration - ) + email = serializer.loads(token, salt=salt, max_age=expiration) except: return return email @@ -103,7 +101,12 @@ def send_confirmation_email(app, user, url, template, header, **kwargs): """ from ..celery import send_email_async - token = generate_confirmation_token(app, user.email) + salt = ( + app.config["SECURITY_EMAIL_SALT"] + if url == "confirm-email" + else app.config["SECURITY_PASSWORD_SALT"] + ) + token = generate_confirmation_token(app, user.email, salt) confirm_url = f"{url}/{token}" html = render_template( template, subject=header, confirm_url=confirm_url, user=user, **kwargs diff --git a/server/mergin/auth/bearer.py b/server/mergin/auth/bearer.py index 117f86d3..1c54a054 100644 --- a/server/mergin/auth/bearer.py +++ b/server/mergin/auth/bearer.py @@ -7,8 +7,7 @@ from flask.sessions import TaggedJSONSerializer -def decode_token(secret_key, token, max_age=None): - salt = "bearer-session" +def decode_token(secret_key, salt, token, max_age=None): serializer = TaggedJSONSerializer() signer_kwargs = {"key_derivation": "hmac", "digest_method": hashlib.sha1} s = URLSafeTimedSerializer( @@ -17,8 +16,7 @@ def decode_token(secret_key, token, max_age=None): return s.loads(token, max_age=max_age) -def encode_token(secret_key, data): - salt = "bearer-session" +def encode_token(secret_key, salt, data): serializer = TaggedJSONSerializer() signer_kwargs = {"key_derivation": "hmac", "digest_method": hashlib.sha1} s = URLSafeTimedSerializer( diff --git a/server/mergin/auth/config.py b/server/mergin/auth/config.py index 4484014e..07b5a05d 100644 --- a/server/mergin/auth/config.py +++ b/server/mergin/auth/config.py @@ -6,6 +6,8 @@ class Configuration(object): + SECURITY_BEARER_SALT = config("SECURITY_BEARER_SALT") + SECURITY_EMAIL_SALT = config("SECURITY_EMAIL_SALT") SECURITY_PASSWORD_SALT = config("SECURITY_PASSWORD_SALT") BEARER_TOKEN_EXPIRATION = config( "BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 106d30bd..7f048046 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -143,7 +143,11 @@ def login_public(): # noqa: E501 "email": user.email, "expire": str(expire), } - token = encode_token(current_app.config["SECRET_KEY"], token_data) + token = encode_token( + current_app.config["SECRET_KEY"], + current_app.config["SECURITY_BEARER_SALT"], + token_data, + ) data = user_profile(user) data["session"] = {"token": token, "expire": expire} @@ -297,7 +301,7 @@ def password_reset(): # pylint: disable=W0613,W0612 def confirm_new_password(token): # pylint: disable=W0613,W0612 - email = confirm_token(token) + email = confirm_token(token, salt=current_app.config["SECURITY_PASSWORD_SALT"]) if not email: abort(400, "Invalid token") @@ -315,7 +319,9 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 def confirm_email(token): # pylint: disable=W0613,W0612 - email = confirm_token(token) + email = confirm_token( + token, expiration=12 * 3600, salt=current_app.config["SECURITY_EMAIL_SALT"] + ) if not email: abort(400, "Invalid token") @@ -375,7 +381,9 @@ def register_user(): # pylint: disable=W0613,W0612 if form.validate(): user = User.create(form.username.data, form.email.data, form.password.data) user_created.send(user, source="admin") - token = generate_confirmation_token(current_app, user.email) + token = generate_confirmation_token( + current_app, user.email, current_app.config["SECURITY_EMAIL_SALT"] + ) confirm_url = f"confirm-email/{token}" html = render_template( "email/user_created.html", diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 280626de..18b50ffb 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import os +import time import pytest import json from flask import url_for @@ -12,7 +13,7 @@ from unittest.mock import patch from mergin.tests import test_workspace - +from ..auth.app import generate_confirmation_token, confirm_token from ..auth.models import User, UserProfile, LoginHistory from ..auth.tasks import anonymize_removed_users from ..app import db @@ -152,26 +153,20 @@ def test_user_register(client, username, email, pwd, expected): def test_confirm_email(app, client): - serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) - token = serializer.dumps( - "mergin@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"] - ) - resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token)) - assert resp.status_code == 200 - user = User.query.filter_by(username="mergin").first() - # tests with old registered user + token = generate_confirmation_token( + app, user.email, app.config["SECURITY_EMAIL_SALT"] + ) user.verified_email = False - user.registration_date = datetime.utcnow() - timedelta(days=1) db.session.commit() - resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token)) - assert resp.status_code == 200 - # try again with freshly registered user - user.verified_email = False - user.registration_date = datetime.utcnow() - db.session.add(user) - db.session.commit() + # verify token can't be used in different context + resp = client.post( + url_for("/.mergin_auth_controller_confirm_new_password", token=token), + json={"password": "ilovemergin#0", "confirm": "ilovemergin#0"}, + ) + assert resp.status_code == 400 + resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token)) assert resp.status_code == 200 @@ -187,21 +182,35 @@ def test_confirm_email(app, client): resp = client.post( url_for( "/.mergin_auth_controller_confirm_email", - token=serializer.dumps( - "tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"] + token=generate_confirmation_token( + app, "tests@mergin.com", app.config["SECURITY_EMAIL_SALT"] ), ) ) assert resp.status_code == 404 + # test expired token + token = generate_confirmation_token( + app, user.email, app.config["SECURITY_EMAIL_SALT"] + ) + time.sleep(2) + assert not confirm_token( + token=token, expiration=1, salt=app.config["SECURITY_EMAIL_SALT"] + ) + def test_confirm_password(app, client): - serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) - token = serializer.dumps( - "mergin@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"] + user = User.query.filter_by(username="mergin").first() + token = generate_confirmation_token( + app, user.email, app.config["SECURITY_PASSWORD_SALT"] ) form_data = {"password": "ilovemergin#0", "confirm": "ilovemergin#0"} + + # verify token can't be used in different context + resp = client.post(url_for("/.mergin_auth_controller_confirm_email", token=token)) + assert resp.status_code == 400 + resp = client.post( url_for("/.mergin_auth_controller_confirm_new_password", token=token), data=json.dumps(form_data), @@ -221,8 +230,8 @@ def test_confirm_password(app, client): resp = client.post( url_for( "/.mergin_auth_controller_confirm_new_password", - token=serializer.dumps( - "tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"] + token=generate_confirmation_token( + app, "tests@mergin.com", app.config["SECURITY_PASSWORD_SALT"] ), ), data=json.dumps(form_data), @@ -240,8 +249,8 @@ def test_confirm_password(app, client): resp = client.post( url_for( "/.mergin_auth_controller_confirm_new_password", - token=serializer.dumps( - "tests@mergin.com", salt=app.config["SECURITY_PASSWORD_SALT"] + token=generate_confirmation_token( + app, "tests@mergin.com", app.config["SECURITY_PASSWORD_SALT"] ), ) ) From c28a14f0512acd3a9bb32fe86313b965923d77c1 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Tue, 21 Jan 2025 09:03:29 +0100 Subject: [PATCH 02/31] Address review comments --- .prod.env | 6 +++++- server/mergin/auth/controller.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.prod.env b/.prod.env index 68f251e0..b6b3dc2a 100644 --- a/.prod.env +++ b/.prod.env @@ -61,9 +61,13 @@ SECRET_KEY=fixme #BEARER_TOKEN_EXPIRATION=3600 * 12 # in seconds -#SECURITY_PASSWORD_SALT=NODEFAULT +#SECURITY_BEARER_SALT=NODEFAULT SECURITY_BEARER_SALT=fixme + +#SECURITY_EMAIL_SALT=NODEFAULT SECURITY_EMAIL_SALT=fixme + +#SECURITY_PASSWORD_SALT=NODEFAULT SECURITY_PASSWORD_SALT=fixme #WTF_CSRF_ENABLED=True diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 7f048046..86fa5610 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -39,6 +39,9 @@ from ..sync.utils import files_size +EMAIL_CONFIRMATION_EXPIRATION = 12 * 3600 + + # public endpoints def user_profile(user, return_all=True): """Return user profile in json format @@ -320,7 +323,9 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 def confirm_email(token): # pylint: disable=W0613,W0612 email = confirm_token( - token, expiration=12 * 3600, salt=current_app.config["SECURITY_EMAIL_SALT"] + token, + expiration=EMAIL_CONFIRMATION_EXPIRATION, + salt=current_app.config["SECURITY_EMAIL_SALT"], ) if not email: abort(400, "Invalid token") From 767de67923065b5d35de755851e59fc545fa89c0 Mon Sep 17 00:00:00 2001 From: "marcel.kocisek" Date: Thu, 23 Jan 2025 15:09:44 +0100 Subject: [PATCH 03/31] FE: impl of report exporter to settings --- web-app/packages/admin-lib/components.d.ts | 1 + .../admin-lib/src/modules/admin/adminApi.ts | 22 ++++- .../admin/components/ReportDownloadDialog.vue | 90 +++++++++++++++++++ .../admin-lib/src/modules/admin/store.ts | 38 +++++++- .../admin-lib/src/modules/admin/types.ts | 5 ++ .../admin/views/SettingsViewTemplate.vue | 28 +++++- .../components/app-settings/AppSettings.vue | 1 + .../app-settings/AppSettingsItem.vue | 4 +- web-app/packages/lib/src/common/date_utils.ts | 10 +++ 9 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 web-app/packages/admin-lib/src/modules/admin/components/ReportDownloadDialog.vue diff --git a/web-app/packages/admin-lib/components.d.ts b/web-app/packages/admin-lib/components.d.ts index 89889c60..bf16d936 100644 --- a/web-app/packages/admin-lib/components.d.ts +++ b/web-app/packages/admin-lib/components.d.ts @@ -9,6 +9,7 @@ declare module 'vue' { export interface GlobalComponents { PAvatar: typeof import('primevue/avatar')['default'] PButton: typeof import('primevue/button')['default'] + PCalendar: typeof import('primevue/calendar')['default'] PColumn: typeof import('primevue/column')['default'] PDataTable: typeof import('primevue/datatable')['default'] PDivider: typeof import('primevue/divider')['default'] diff --git a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts index 6380ef6c..08114297 100644 --- a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts +++ b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts @@ -18,7 +18,8 @@ import { CreateUserData, PaginatedAdminProjectsResponse, PaginatedAdminProjectsParams, - ServerUsageResponse + ServerUsageResponse, + DownloadReportParams } from '@/modules/admin/types' export const AdminApi = { @@ -99,6 +100,23 @@ export const AdminApi = { }, async getServerUsage(): Promise> { - return AdminModule.httpService.get('/app/admin/usage', ) + return AdminModule.httpService.get('/app/admin/usage') + }, + + /** + * Create url for csv file with server statistics + */ + contructDownloadStatisticsUrl(): string { + return AdminModule.httpService.absUrl('/app/admin/report') + }, + + async downloadStatistics( + url: string, + params: DownloadReportParams + ): Promise> { + return AdminModule.httpService.get(url, { + responseType: 'blob', + params + }) } } diff --git a/web-app/packages/admin-lib/src/modules/admin/components/ReportDownloadDialog.vue b/web-app/packages/admin-lib/src/modules/admin/components/ReportDownloadDialog.vue new file mode 100644 index 00000000..a092d9cf --- /dev/null +++ b/web-app/packages/admin-lib/src/modules/admin/components/ReportDownloadDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/web-app/packages/admin-lib/src/modules/admin/store.ts b/web-app/packages/admin-lib/src/modules/admin/store.ts index 7b9281ff..46b3e9af 100644 --- a/web-app/packages/admin-lib/src/modules/admin/store.ts +++ b/web-app/packages/admin-lib/src/modules/admin/store.ts @@ -13,6 +13,7 @@ import { useNotificationStore, UserResponse } from '@mergin/lib' +import FileSaver from 'file-saver' import { defineStore, getActivePinia } from 'pinia' import Cookies from 'universal-cookie' @@ -22,10 +23,12 @@ import { AdminApi } from '@/modules/admin/adminApi' import { LatestServerVersionResponse, PaginatedAdminProjectsParams, - PaginatedAdminProjectsResponse, ServerUsageResponse, + PaginatedAdminProjectsResponse, + ServerUsageResponse, UpdateUserPayload, UsersResponse } from '@/modules/admin/types' +import axios from 'axios' export interface AdminState { loading: boolean @@ -325,6 +328,39 @@ export const useAdminStore = defineStore('adminModule', { } catch (e) { notificationStore.error({ text: errorUtils.getErrorMessage(e) }) } + }, + + async downloadReport(payload: { from: Date; to: Date }) { + const notificationStore = useNotificationStore() + + try { + const { from, to } = payload + const url = AdminApi.contructDownloadStatisticsUrl() + const date = new Date() + // we need to sanitize hours from custom range in Calendar picker, because it's 00:00:00 by default. If you convert it to UTC -> it could be previous day, which is not what you selected. + from.setHours(date.getHours(), date.getMinutes(), date.getSeconds()) + to.setHours(date.getHours(), date.getMinutes(), date.getSeconds()) + // toISOString converts date to iso format and UTC zone + const date_from = payload.from.toISOString().split('T')[0] + const date_to = payload.to.toISOString().split('T')[0] + const resp = await AdminApi.downloadStatistics(url, { + date_from, + date_to + }) + const fileName = + resp.headers['content-disposition'].split('filename=')[1] + FileSaver.saveAs(resp.data, fileName) + } catch (e) { + // parse error details from blob + if (axios.isAxiosError(e)) { + let resp + const blob = new Blob([e.response.data], { type: 'text/plain' }) + blob.text().then((text) => { + resp = JSON.parse(text) + notificationStore.error({ text: resp.detail }) + }) + } + } } } }) diff --git a/web-app/packages/admin-lib/src/modules/admin/types.ts b/web-app/packages/admin-lib/src/modules/admin/types.ts index 2c3bc54d..e443b0a7 100644 --- a/web-app/packages/admin-lib/src/modules/admin/types.ts +++ b/web-app/packages/admin-lib/src/modules/admin/types.ts @@ -80,4 +80,9 @@ export interface ServerUsage { editors: number } +export interface DownloadReportParams { + date_from: string + date_to: string +} + /* eslint-enable camelcase */ diff --git a/web-app/packages/admin-lib/src/modules/admin/views/SettingsViewTemplate.vue b/web-app/packages/admin-lib/src/modules/admin/views/SettingsViewTemplate.vue index ee4880c7..5ef98bf2 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/SettingsViewTemplate.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/SettingsViewTemplate.vue @@ -16,12 +16,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial - + + + @@ -33,9 +40,11 @@ import { AppContainer, AppSection, AppSettings, - AppSettingsItemConfig + AppSettingsItemConfig, + useDialogStore } from '@mergin/lib' +import ReportDownloadDialog from '../components/ReportDownloadDialog.vue' import { useAdminStore } from '../store' import AdminLayout from '@/modules/admin/components/AdminLayout.vue' @@ -46,16 +55,31 @@ withDefaults(defineProps<{ settingsItems?: AppSettingsItemConfig[] }>(), { title: 'Check for updates', description: 'Let Mergin Maps automatically check for new updates', key: 'checkForUpdates' + }, + { + title: 'Server usage report', + description: 'Download usage statistics for your server deployment.', + key: 'downloadReport' } ] }) const adminStore = useAdminStore() +const dialogStore = useDialogStore() function switchCheckForUpdates() { const value = !adminStore.checkForUpdates adminStore.setCheckUpdatesToCookies({ value }) } + +function downloadReport() { + dialogStore.show({ + component: ReportDownloadDialog, + params: { + dialog: { header: 'Doownload usage report' } + } + }) +} diff --git a/web-app/packages/lib/src/common/components/app-settings/AppSettings.vue b/web-app/packages/lib/src/common/components/app-settings/AppSettings.vue index 4627492d..29cd1800 100644 --- a/web-app/packages/lib/src/common/components/app-settings/AppSettings.vue +++ b/web-app/packages/lib/src/common/components/app-settings/AppSettings.vue @@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -->