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 31499ad3..7587a622 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/tests/test_auth.py b/server/mergin/tests/test_auth.py index a5217f6a..cb4de697 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/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

-
+