diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index 095b0d26..e0771689 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -68,8 +68,8 @@ paths: post: tags: - user - summary: Update profile of user in sesssion - description: Update profile of user in sesssion + summary: Update profile of user in session + description: Update profile of user in session operationId: mergin.auth.controller.update_user_profile requestBody: description: Updated profile @@ -101,6 +101,8 @@ paths: $ref: "#/components/responses/BadStatusResp" "401": $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/Forbidden" /app/auth/refresh/csrf: get: summary: Get refreshed csrf token @@ -426,6 +428,8 @@ paths: description: OK "400": $ref: "#/components/responses/BadStatusResp" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFoundResp" /app/auth/reset-password/{token}: @@ -462,6 +466,8 @@ paths: description: OK "400": $ref: "#/components/responses/BadStatusResp" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFoundResp" /app/auth/confirm-email/{token}: @@ -895,6 +901,8 @@ components: example: my-workspace role: $ref: "#/components/schemas/WorkspaceRole" + can_edit_profile: + type: boolean LoginResponse: allOf: - $ref: "#/components/schemas/UserDetail" diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index 7d3d8e29..acfccf43 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -11,12 +11,14 @@ from .commands import add_commands from .config import Configuration -from .models import User, UserProfile +from .models import User # signal for other versions to listen to user_account_closed = signal("user_account_closed") user_created = signal("user_created") +CANNOT_EDIT_PROFILE_MSG = "You cannot edit profile of this user" + def register(app): """Register mergin auth module in Flask app @@ -70,6 +72,20 @@ def wrapped_func(*args, **kwargs): return wrapped_func +def edit_profile_enabled(f): + """Decorator to check if user can edit their profile (it is not allowed for SSO users)""" + + @functools.wraps(f) + def wrapped_func(*args, **kwargs): + if not current_user or not current_user.is_authenticated: + return "Authentication information is missing or invalid.", 401 + if not current_user.can_edit_profile: + return CANNOT_EDIT_PROFILE_MSG, 403 + return f(*args, **kwargs) + + return wrapped_func + + def authenticate(login, password): if "@" in login: query = func.lower(User.email) == func.lower(login) diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 980bc14c..3e00ce16 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -20,6 +20,8 @@ generate_confirmation_token, user_created, user_account_closed, + edit_profile_enabled, + CANNOT_EDIT_PROFILE_MSG, ) from .bearer import encode_token from .models import User, LoginHistory, UserProfile @@ -254,6 +256,7 @@ def logout(): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def change_password(): # pylint: disable=W0613,W0612 form = UserChangePasswordForm() if form.validate_on_submit(): @@ -268,6 +271,7 @@ def change_password(): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def resend_confirm_email(): # pylint: disable=W0613,W0612 send_confirmation_email( current_app, @@ -292,6 +296,9 @@ def password_reset(): # pylint: disable=W0613,W0612 if not user.active: # user should confirm email first return jsonify({"email": ["Account is not active"]}), 400 + if not user.can_edit_profile: + # using SSO + abort(403, CANNOT_EDIT_PROFILE_MSG) send_confirmation_email( current_app, @@ -311,6 +318,8 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 user = User.query.filter_by(email=email).first_or_404() if not user.active: abort(400, "Account is not active") + if not user.can_edit_profile: + abort(403, CANNOT_EDIT_PROFILE_MSG) form = UserPasswordForm.from_json(request.json) if form.validate(): @@ -331,6 +340,8 @@ def confirm_email(token): # pylint: disable=W0613,W0612 abort(400, "Invalid token") user = User.query.filter_by(email=email).first_or_404() + if not user.can_edit_profile: + abort(403, CANNOT_EDIT_PROFILE_MSG) if user.verified_email: return "", 200 @@ -343,6 +354,7 @@ def confirm_email(token): # pylint: disable=W0613,W0612 @auth_required +@edit_profile_enabled def update_user_profile(): # pylint: disable=W0613,W0612 form = UserProfileDataForm.from_json(request.json) email_changed = current_user.email != form.email.data.strip() diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index e697c3f5..3447d654 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -41,7 +41,7 @@ class User(db.Model): db.Index("ix_user_email", func.lower(email), unique=True), ) - def __init__(self, username, email, passwd, is_admin=False): + def __init__(self, username, email, passwd=None, is_admin=False): self.username = username self.email = email self.assign_password(passwd) @@ -58,7 +58,11 @@ def check_password(self, password): def assign_password(self, password): if isinstance(password, str): password = password.encode("utf-8") - self.passwd = bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8") + self.passwd = ( + bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8") + if password + else None + ) @property def is_authenticated(self): @@ -236,6 +240,12 @@ def create( db.session.commit() return user + @property + def can_edit_profile(self) -> bool: + """Flag if we allow user to edit their email and name""" + # False when user is created by SSO login + return self.passwd is not None and self.active + class UserProfile(db.Model): user_id = db.Column( diff --git a/server/mergin/auth/schemas.py b/server/mergin/auth/schemas.py index 62d0bd1e..3f614ae1 100644 --- a/server/mergin/auth/schemas.py +++ b/server/mergin/auth/schemas.py @@ -101,6 +101,7 @@ class UserInfoSchema(ma.SQLAlchemyAutoSchema): receive_notifications = fields.Boolean(attribute="profile.receive_notifications") registration_date = DateTimeWithZ(attribute="registration_date") name = fields.Function(lambda obj: obj.profile.name()) + can_edit_profile = fields.Boolean(attribute="can_edit_profile") class Meta: model = User diff --git a/server/mergin/commands.py b/server/mergin/commands.py index eef371fa..f02a536c 100644 --- a/server/mergin/commands.py +++ b/server/mergin/commands.py @@ -95,6 +95,7 @@ def _check_permissions(path): def _check_server(): # pylint: disable=W0612 """Check server configuration.""" + from .stats.models import MerginInfo _echo_title("Server health check") edition_map = { @@ -122,6 +123,14 @@ def _check_server(): # pylint: disable=W0612 else: click.secho(f"Your contact email is {contact_email}.", fg="green") + info = MerginInfo.query.first() + service_id = app.config.get("SERVICE_ID") or (info.service_id if info else None) + + if service_id: + click.secho(f"Service ID is {service_id}.", fg="green") + else: + _echo_error("No service ID set.") + tables = db.engine.table_names() if not tables: _echo_error("Database not initialized. Run flask init-db command") diff --git a/server/mergin/stats/models.py b/server/mergin/stats/models.py index 3c32e887..cd67d01f 100644 --- a/server/mergin/stats/models.py +++ b/server/mergin/stats/models.py @@ -24,6 +24,7 @@ class ServerCallhomeData: server_version: Optional[str] monthly_contributors: Optional[int] editors: Optional[int] + sso_connections: Optional[int] class MerginInfo(db.Model): diff --git a/server/mergin/stats/tasks.py b/server/mergin/stats/tasks.py index 9812340d..a98365db 100644 --- a/server/mergin/stats/tasks.py +++ b/server/mergin/stats/tasks.py @@ -39,6 +39,7 @@ def get_callhome_data(info: MerginInfo | None = None) -> ServerCallhomeData: server_version=current_app.config["VERSION"], monthly_contributors=current_app.ws_handler.monthly_contributors_count(), editors=current_app.ws_handler.server_editors_count(), + sso_connections=current_app.ws_handler.sso_connections_count(), ) return data diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 79aef2b9..a9818003 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -373,3 +373,8 @@ def server_editors_count(self) -> int: .group_by(ProjectUser.user_id) .count() ) + + @staticmethod + def sso_connections_count() -> int: + """Number of SSO connections for the server""" + return 0 diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index b717b4e4..2836c7cf 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -502,6 +502,19 @@ def test_update_user_profile(client): assert not user.verified_email assert user.email == "changed_email@mergin.co.uk" + # do not allow to update sso user + sso_user = add_user("sso_user", "sso") + login(client, sso_user.username, "sso") + sso_user.passwd = None + db.session.add(sso_user) + db.session.commit() + resp = client.post( + url_for("/.mergin_auth_controller_update_user_profile"), + data=json.dumps({"email": "changed_email@sso.co.uk"}), + headers=json_headers, + ) + assert resp.status_code == 403 + def test_search_user(client): user = User.query.filter_by(username="mergin").first() diff --git a/server/mergin/tests/test_statistics.py b/server/mergin/tests/test_statistics.py index a87ba08c..6f339505 100644 --- a/server/mergin/tests/test_statistics.py +++ b/server/mergin/tests/test_statistics.py @@ -63,6 +63,7 @@ def test_send_statistics(app, caplog): "server_version", "monthly_contributors", "editors", + "sso_connections", } assert data["workspaces_count"] == 1 assert data["service_uuid"] == app.config["SERVICE_ID"] @@ -72,6 +73,7 @@ def test_send_statistics(app, caplog): assert data["projects_count"] == 2 assert data["contact_email"] == "test@example.com" assert data["editors"] == 2 + assert data["sso_connections"] == 0 # repeated action does not do anything task = send_statistics.s().apply() diff --git a/web-app/packages/lib/src/modules/layout/types.ts b/web-app/packages/lib/src/modules/layout/types.ts index 38b3e873..51ebe39f 100644 --- a/web-app/packages/lib/src/modules/layout/types.ts +++ b/web-app/packages/lib/src/modules/layout/types.ts @@ -7,5 +7,5 @@ export interface SideBarItemModel { to?: string href?: string icon: string - active: boolean + active?: boolean } diff --git a/web-app/packages/lib/src/modules/user/types.ts b/web-app/packages/lib/src/modules/user/types.ts index 2305c053..bb1f0b29 100644 --- a/web-app/packages/lib/src/modules/user/types.ts +++ b/web-app/packages/lib/src/modules/user/types.ts @@ -66,6 +66,7 @@ export interface UserDetailResponse extends UserProfileResponse { receive_notifications: boolean registration_date: string workspaces: UserWorkspace[] + can_edit_profile: boolean } export interface WorkspaceResponse extends UserWorkspace { diff --git a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue index 4c6632a3..7326f055 100644 --- a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue +++ b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue @@ -12,7 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial >

Account details

-
+