This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 758ace28cd [Cherry-pick to branch-1.3] [#13925]
improvement(idp-basic): Require currentPassword for self password change
(#13926) (#13963)
758ace28cd is described below
commit 758ace28cd252497f9cf70d0326138ef1cb59d39
Author: MaSai <[email protected]>
AuthorDate: Tue Oct 20 15:03:27 2026 +0800
[Cherry-pick to branch-1.3] [#13925] improvement(idp-basic): Require
currentPassword for self password change (#13926) (#13963)
**Cherry-pick Information:**
- Original commit: d7e8b22ca56f4dab50c7ad728cf287ca735374b7
- Target branch: `branch-1.3`
- Status: Clean cherry-pick adapted for branch-1.3 (no conflict
markers).
### What changes were proposed in this pull request?
Backport [#13926](https://github.com/apache/gravitino/pull/13926) /
[#13925](https://github.com/apache/gravitino/issues/13925) to
`branch-1.3`:
- Require `oldPassword` for self password changes and verify it against
the stored hash
- Move password changes to `PUT /api/idp/users/{user}/password` with
`newPassword` / optional `oldPassword`
- Allow authenticated non-admin users to `GET` their own user and `PUT`
their own password
- Service admins resetting another user send only `newPassword` and must
omit `oldPassword`
### Backport notes
- `branch-1.3` does not include the local-user `enabled` flag / `PUT
.../enabled` API from main, so that part of #13926 is omitted.
- Replaces auto cherry-pick
[#13956](https://github.com/apache/gravitino/pull/13956), which landed
with unresolved conflict markers.
### Why are the changes needed?
Self-service (and admin self) password change previously accepted only
the new password. Callers with a valid Basic session could change a
password without proving knowledge of the existing password again.
Fix: #13925
### Does this PR introduce _any_ user-facing change?
Yes.
- Password change path: `PUT /api/idp/users/{user}/password`
- Self-change body: `{"newPassword":"...","oldPassword":"..."}`
- Admin reset of another user: `{"newPassword":"..."}` (must omit
`oldPassword`)
- Create-user still uses `password`
### How was this patch tested?
- `./gradlew :plugins:idp-basic:test --tests
org.apache.gravitino.idp.TestIdpUserGroupManager --tests
org.apache.gravitino.idp.dto.requests.TestChangePasswordRequest --tests
org.apache.gravitino.idp.web.rest.TestIdpOperations --tests
org.apache.gravitino.idp.web.rest.TestIdpAuthorizationFilter -PskipITs`
- `./gradlew :docs:build`
Made with [Cursor](https://cursor.com)
Co-authored-by: Cursor <[email protected]>
---
design-docs/gravitino-local-authentication.md | 12 ++--
docs/open-api/idp/idp.yaml | 75 ++++++++++++++--------
docs/open-api/idp/openapi.yaml | 3 +
docs/security/local-users-and-groups.md | 47 +++++++++++---
.../apache/gravitino/idp/IdpUserGroupManager.java | 18 ++++++
.../idp/basic/IdpCredentialValidator.java | 14 +++-
.../idp/dto/requests/ChangePasswordRequest.java | 36 ++++++++---
.../org/apache/gravitino/idp/web/IdpRESTUtils.java | 4 ++
.../idp/web/rest/IdpAuthorizationFilter.java | 44 +++++++++++--
.../gravitino/idp/web/rest/IdpUserOperations.java | 38 ++++++++++-
.../gravitino/idp/TestIdpUserGroupManager.java | 16 +++++
.../dto/requests/TestChangePasswordRequest.java | 43 +++++++------
.../idp/integration/test/IdpRESTApiIT.java | 59 +++++++++++++++--
.../idp/web/rest/TestIdpAuthorizationFilter.java | 45 +++++++++++++
.../gravitino/idp/web/rest/TestIdpOperations.java | 56 +++++++++++++++-
15 files changed, 429 insertions(+), 81 deletions(-)
diff --git a/design-docs/gravitino-local-authentication.md
b/design-docs/gravitino-local-authentication.md
index 77e64c6010..4cae6f30f2 100644
--- a/design-docs/gravitino-local-authentication.md
+++ b/design-docs/gravitino-local-authentication.md
@@ -459,16 +459,18 @@ http://localhost:8090/api/idp/users/alice
```
-#### 9.1.4 Reset a local user password
+#### 9.1.4 Change a local user password
-`PUT /api/idp/users/{user}` — administrator-only reset (`password` only; no
`oldPassword`). Usernames
-must not contain `:`. Password length is 12–64 characters.
+`PUT /api/idp/users/{user}/password` — service admins may reset any user's
password with
+`newPassword` only (must omit `oldPassword`). Authenticated non-admin users
may change only their
+own password and must also send `oldPassword`. Usernames must not contain `:`.
Password length is
+12–64 characters.
```shell
curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
-H "Content-Type: application/json" -d '{
- "password": "Passw0rd-For-Gravitino-Alice-V2"
-}' http://localhost:8090/api/idp/users/alice
+ "newPassword": "Passw0rd-For-Gravitino-Alice-V2"
+}' http://localhost:8090/api/idp/users/alice/password
```
#### 9.1.5 Get a local group
diff --git a/docs/open-api/idp/idp.yaml b/docs/open-api/idp/idp.yaml
index a3c793f414..74e64a395b 100644
--- a/docs/open-api/idp/idp.yaml
+++ b/docs/open-api/idp/idp.yaml
@@ -106,11 +106,34 @@ paths:
"5xx":
$ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
+ delete:
+ tags:
+ - IDP
+ summary: Remove local user store user
+ description: Removes the specified local user store user.
+ operationId: removeIdpUser
+ responses:
+ "200":
+ $ref: "../openapi.yaml#/components/responses/RemoveResponse"
+ "403":
+ $ref: "#/components/responses/IdpForbiddenErrorResponse"
+ "5xx":
+ $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
+
+ /idp/users/{user}/password:
+ parameters:
+ - $ref: "../openapi.yaml#/components/parameters/user"
+
put:
tags:
- IDP
summary: Change local user store user password
- description: Updates the password of the specified local user store user
and returns the user object with unchanged group membership.
+ description: >
+ Changes the password of the specified local user store user and
returns the user object.
+ Service admins may reset any user's password. Authenticated non-admin
users may change only
+ their own password. Changing your own password requires `oldPassword`
and verifies it
+ against the stored hash. A service admin resetting another user's
password must omit
+ `oldPassword`.
operationId: changeIdpUserPassword
requestBody:
required: true
@@ -119,8 +142,10 @@ paths:
schema:
$ref: "#/components/schemas/ChangePasswordRequest"
examples:
- ChangePasswordRequest:
- $ref: "#/components/examples/ChangePasswordRequest"
+ ChangeUserPasswordRequest:
+ $ref: "#/components/examples/ChangeUserPasswordRequest"
+ ChangeOwnUserPasswordRequest:
+ $ref: "#/components/examples/ChangeOwnUserPasswordRequest"
responses:
"200":
description: Returns the local user store user after the password
change
@@ -154,20 +179,6 @@ paths:
"5xx":
$ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
- delete:
- tags:
- - IDP
- summary: Remove local user store user
- description: Removes the specified local user store user.
- operationId: removeIdpUser
- responses:
- "200":
- $ref: "../openapi.yaml#/components/responses/RemoveResponse"
- "403":
- $ref: "#/components/responses/IdpForbiddenErrorResponse"
- "5xx":
- $ref: "../openapi.yaml#/components/responses/ServerErrorResponse"
-
/idp/groups:
post:
tags:
@@ -387,15 +398,22 @@ components:
ChangePasswordRequest:
type: object
required:
- - password
+ - newPassword
properties:
- password:
+ newPassword:
type: string
format: password
minLength: 12
maxLength: 64
description: The new password of the local user store user.
writeOnly: true
+ oldPassword:
+ type: string
+ format: password
+ description: >
+ The caller's current password. Required when changing your own
password; must be
+ omitted when a service admin resets another user's password.
+ writeOnly: true
AddGroupRequest:
type: object
@@ -460,9 +478,10 @@ components:
responses:
IdpForbiddenErrorResponse:
description:
- Forbidden - Only callers listed in
`gravitino.authorization.serviceAdmins` may
- manage local user store users and groups. Unauthenticated requests and
authenticated
- non-service-admin users receive this response.
+ Forbidden - Callers listed in `gravitino.authorization.serviceAdmins`
may manage local
+ user store users and groups. Authenticated non-admin users may only
`GET`
+ `/idp/users/{user}` or `PUT` `/idp/users/{user}/password` for their
own username.
+ Unauthenticated requests and other non-admin IdP management calls
receive this response.
content:
application/vnd.gravitino.v1+json:
schema:
@@ -478,9 +497,15 @@ components:
"password": "Passw0rd-1234"
}
- ChangePasswordRequest:
+ ChangeUserPasswordRequest:
+ value: {
+ "newPassword": "Passw0rd-5678"
+ }
+
+ ChangeOwnUserPasswordRequest:
value: {
- "password": "Passw0rd-5678"
+ "newPassword": "Passw0rd-5678",
+ "oldPassword": "Passw0rd-1234"
}
AddGroupRequest:
@@ -559,7 +584,7 @@ components:
value: {
"code": 1001,
"type": "IllegalArgumentException",
- "message": "Failed to operate local user store user [alice] operation
[UPDATE], reason [\"password\" field is required and cannot be empty]"
+ "message": "Failed to operate local user store user [alice] operation
[UPDATE], reason [\"newPassword\" field is required and cannot be empty]"
}
IdpAddGroupIllegalArgumentException:
diff --git a/docs/open-api/idp/openapi.yaml b/docs/open-api/idp/openapi.yaml
index 263c50c7fa..782edd3578 100644
--- a/docs/open-api/idp/openapi.yaml
+++ b/docs/open-api/idp/openapi.yaml
@@ -58,6 +58,9 @@ paths:
/idp/users/{user}:
$ref: "./idp.yaml#/paths/~1idp~1users~1%7Buser%7D"
+ /idp/users/{user}/password:
+ $ref: "./idp.yaml#/paths/~1idp~1users~1%7Buser%7D~1password"
+
/idp/groups:
$ref: "./idp.yaml#/paths/~1idp~1groups"
diff --git a/docs/security/local-users-and-groups.md
b/docs/security/local-users-and-groups.md
index 069ab8d31d..1114b2fd96 100644
--- a/docs/security/local-users-and-groups.md
+++ b/docs/security/local-users-and-groups.md
@@ -73,22 +73,35 @@ These rules apply to user creation, password changes, and
`GRAVITINO_INITIAL_ADM
| Username | Required, and must not contain a colon |
| Password length | 12 to 64 characters inclusive |
-Passwords are reset by an administrator rather than changed by the user, so a
password change request carries the new password only and no current password.
+Passwords may be reset by a service admin or changed by the user for their own
account.
+Callers authenticate with HTTP Basic. A self-service password change must also
send
+`oldPassword` in the request body so the server can verify it against the
stored hash.
+A service admin resetting another user's password sends only `newPassword` and
must not
+send `oldPassword`.
## Managing Users and Groups
-All management endpoints are under `http://{host}:{port}/api/idp` and require
Basic authentication as a service admin. Send `Accept:
application/vnd.gravitino.v1+json` on every request, and `Content-Type:
application/json` on requests with a body.
+All management endpoints are under `http://{host}:{port}/api/idp` and require
Basic authentication.
+Most operations require a service admin. Authenticated users may also `GET`
+`/api/idp/users/{user}` or `PUT` `/api/idp/users/{user}/password` for their
own username
+(read profile / change password only).
+Send `Accept: application/vnd.gravitino.v1+json` on every request, and
+`Content-Type: application/json` on requests with a body.
### User Operations
-| Operation | Method | Path | Body
|
-|-----------------|--------|--------------------------|---------------------------------------------|
-| Get a user | GET | `/api/idp/users/{user}` | None
|
-| Add a user | POST | `/api/idp/users` |
`{"user":"alice","password":"{password}"}` |
-| Reset a password| PUT | `/api/idp/users/{user}` |
`{"password":"{new_password}"}` |
-| Remove a user | DELETE | `/api/idp/users/{user}` | None
|
+| Operation | Method | Path | Body
|
+|------------------------|--------|----------------------------------|------------------------------------------------------------------|
+| Get a user | GET | `/api/idp/users/{user}` | None
|
+| Add a user | POST | `/api/idp/users` |
`{"user":"alice","password":"{password}"}` |
+| Change a user password | PUT | `/api/idp/users/{user}/password` |
`{"newPassword":"{new_password}"}` (plus `oldPassword` for self) |
+| Remove a user | DELETE | `/api/idp/users/{user}` | None
|
-The add-user body uses the field name `user` rather than `name`.
+The add-user body uses the field name `user` rather than `name`. Password
changes use
+`PUT /api/idp/users/{user}/password` with required `newPassword`. Non-admin
callers may get
+only their own user and change only their own password. When changing your own
password,
+`oldPassword` is required and must match the stored password. When a service
admin resets
+another user's password, `oldPassword` must be omitted.
```shell
curl -s -X POST -H "Accept: application/vnd.gravitino.v1+json" \
@@ -98,6 +111,22 @@ curl -s -X POST -H "Accept:
application/vnd.gravitino.v1+json" \
http://localhost:8090/api/idp/users
```
+```shell
+curl -s -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Basic $(echo -n 'alice:{user_password}' | base64)" \
+ -d '{"newPassword":"{new_password}","oldPassword":"{user_password}"}' \
+ http://localhost:8090/api/idp/users/alice/password
+```
+
+```shell
+curl -s -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Basic $(echo -n 'admin:{admin_password}' | base64)" \
+ -d '{"newPassword":"{new_password}"}' \
+ http://localhost:8090/api/idp/users/alice/password
+```
+
### Group Operations
| Operation | Method | Path
| Body |
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
index 72e36d73f3..e866708892 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/IdpUserGroupManager.java
@@ -176,6 +176,24 @@ public class IdpUserGroupManager implements Closeable {
return USER_SERVICE.updateIdpUserPassword(username,
passwordHasher.hash(password));
}
+ /**
+ * Changes the password for a built-in IdP user after verifying the old
password.
+ *
+ * @param username The username.
+ * @param newPassword The new plaintext password.
+ * @param oldPassword The caller's current plaintext password.
+ * @return {@code true} if the password was updated
+ * @throws NotFoundException if the user does not exist
+ * @throws IllegalArgumentException if {@code oldPassword} does not match
the stored hash
+ */
+ public boolean changePassword(String username, String newPassword, String
oldPassword) {
+ IdpUser user = USER_SERVICE.getIdpUser(username);
+ if (user.passwordHash() == null || !passwordHasher.verify(oldPassword,
user.passwordHash())) {
+ throw new IllegalArgumentException("\"oldPassword\" does not match the
stored password");
+ }
+ return changePassword(username, newPassword);
+ }
+
/**
* Adds a built-in IdP group.
*
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
index b27855c577..66f0187d42 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/IdpCredentialValidator.java
@@ -54,8 +54,20 @@ public final class IdpCredentialValidator {
}
public static void validatePassword(String password) {
+ validatePassword(password, "password");
+ }
+
+ /**
+ * Validates a password field.
+ *
+ * @param password the password value
+ * @param fieldName the JSON/request field name used in error messages
+ */
+ public static void validatePassword(String password, String fieldName) {
Preconditions.checkArgument(
- StringUtils.isNotBlank(password), "\"password\" field is required and
cannot be empty");
+ StringUtils.isNotBlank(password),
+ "\"%s\" field is required and cannot be empty",
+ fieldName);
Preconditions.checkArgument(
password.length() >= MIN_PASSWORD_LENGTH && password.length() <=
MAX_PASSWORD_LENGTH,
"Password must be at least %s characters long and at most %s
characters long",
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
index f923585adf..727dc7127d 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/dto/requests/ChangePasswordRequest.java
@@ -7,7 +7,7 @@
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
@@ -19,6 +19,7 @@
package org.apache.gravitino.idp.dto.requests;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.EqualsAndHashCode;
@@ -36,23 +37,40 @@ import org.apache.gravitino.rest.RESTRequest;
@Jacksonized
public class ChangePasswordRequest implements RESTRequest {
- @JsonProperty("password")
+ @JsonProperty("newPassword")
@ToString.Exclude
- private final String password;
+ private final String newPassword;
+
+ @JsonProperty("oldPassword")
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @ToString.Exclude
+ private final String oldPassword;
/** Default constructor for ChangePasswordRequest. (Used for Jackson
deserialization.) */
public ChangePasswordRequest() {
- this(null);
+ this(null, null);
+ }
+
+ /**
+ * Creates a ChangePasswordRequest for an admin password reset (no old
password).
+ *
+ * @param newPassword The new password.
+ */
+ public ChangePasswordRequest(String newPassword) {
+ this(newPassword, null);
}
/**
- * Creates a new ChangePasswordRequest.
+ * Creates a ChangePasswordRequest.
*
- * @param password The new password of the built-in IdP user.
+ * @param newPassword The new password.
+ * @param oldPassword The caller's current password when changing their own
password, or {@code
+ * null} when a service admin resets another user's password.
*/
- public ChangePasswordRequest(String password) {
+ public ChangePasswordRequest(String newPassword, String oldPassword) {
super();
- this.password = password;
+ this.newPassword = newPassword;
+ this.oldPassword = oldPassword;
}
/**
@@ -62,6 +80,6 @@ public class ChangePasswordRequest implements RESTRequest {
*/
@Override
public void validate() throws IllegalArgumentException {
- IdpCredentialValidator.validatePassword(password);
+ IdpCredentialValidator.validatePassword(newPassword, "newPassword");
}
}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
index 089505654d..35a2831b5a 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.exceptions.AlreadyExistsException;
+import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.exceptions.NotFoundException;
import org.apache.gravitino.server.web.ServerHealth;
@@ -119,6 +120,9 @@ public final class IdpRESTUtils {
if (e instanceof IllegalArgumentException) {
return illegalArguments(errorMsg, e);
}
+ if (e instanceof ForbiddenException) {
+ return forbidden(errorMsg, e);
+ }
if (e instanceof NotFoundException) {
return notFound(errorMsg, e);
}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
index 6bad34ea3f..f445778881 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpAuthorizationFilter.java
@@ -23,6 +23,7 @@ import java.util.List;
import java.util.function.Supplier;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.core.PathSegment;
import org.apache.gravitino.Configs;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.idp.web.IdpManagement;
@@ -32,9 +33,13 @@ import org.apache.gravitino.utils.PrincipalUtils;
/**
* Enforces built-in IdP management API access rules without server
interception.
*
- * <p>Caller must be listed in {@link Configs#SERVICE_ADMINS}. This filter
runs as a Jersey request
- * filter after the servlet {@code AuthenticationFilter} has authenticated the
caller and populated
- * the current user principal.
+ * <p>Callers listed in {@link Configs#SERVICE_ADMINS} may perform all IdP
management operations.
+ * Authenticated non-admin users may {@code GET} {@code /idp/users/{user}} or
{@code PUT} {@code
+ * /idp/users/{user}/password} only for their own username. Other IdP
management APIs remain
+ * service-admin only.
+ *
+ * <p>This filter runs as a Jersey request filter after the servlet {@code
AuthenticationFilter} has
+ * authenticated the caller and populated the current user principal.
*
* <p>Registered only when {@code basic} is configured in {@link
Configs#AUTHENTICATORS}. Scoped to
* resources annotated with {@link IdpManagement} via Jersey name binding.
@@ -42,7 +47,7 @@ import org.apache.gravitino.utils.PrincipalUtils;
@IdpManagement
public class IdpAuthorizationFilter implements ContainerRequestFilter {
- /** Error message when the caller is not a service admin. */
+ /** Error message when the caller is not allowed to perform the IdP
management operation. */
public static final String SERVICE_ADMIN_REQUIRED_MESSAGE =
"Only service admins can manage built-in IdP users and groups.";
@@ -64,12 +69,39 @@ public class IdpAuthorizationFilter implements
ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) throws
IOException {
- if (!isServiceAdmin(serviceAdminsSupplier.get(),
currentUserSupplier.get())) {
-
requestContext.abortWith(IdpRESTUtils.forbidden(SERVICE_ADMIN_REQUIRED_MESSAGE,
null));
+ String currentUser = currentUserSupplier.get();
+ if (isServiceAdmin(serviceAdminsSupplier.get(), currentUser)
+ || isSelfUserAccess(requestContext, currentUser)) {
+ return;
}
+
requestContext.abortWith(IdpRESTUtils.forbidden(SERVICE_ADMIN_REQUIRED_MESSAGE,
null));
}
static boolean isServiceAdmin(List<String> serviceAdmins, String
currentUser) {
return currentUser != null && serviceAdmins != null &&
serviceAdmins.contains(currentUser);
}
+
+ /**
+ * Returns whether the request is a self-service user access: {@code GET
/idp/users/{currentUser}}
+ * or {@code PUT /idp/users/{currentUser}/password}.
+ */
+ static boolean isSelfUserAccess(ContainerRequestContext requestContext,
String currentUser) {
+ if (currentUser == null) {
+ return false;
+ }
+ List<PathSegment> segments = requestContext.getUriInfo().getPathSegments();
+ if (segments.size() < 3
+ || !"idp".equals(segments.get(0).getPath())
+ || !"users".equals(segments.get(1).getPath())
+ || !currentUser.equals(segments.get(2).getPath())) {
+ return false;
+ }
+ String method = requestContext.getMethod();
+ if ("GET".equalsIgnoreCase(method) && segments.size() == 3) {
+ return true;
+ }
+ return "PUT".equalsIgnoreCase(method)
+ && segments.size() == 4
+ && "password".equals(segments.get(3).getPath());
+ }
}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
index 9d73e30b3c..0335b41cf5 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/rest/IdpUserOperations.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.idp.web.rest;
import com.codahale.metrics.annotation.ResponseMetered;
import com.codahale.metrics.annotation.Timed;
+import com.google.common.base.Preconditions;
+import java.util.List;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DELETE;
@@ -31,7 +33,11 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.dto.responses.RemoveResponse;
+import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.idp.IdpUserGroupManager;
import org.apache.gravitino.idp.dto.requests.AddUserRequest;
import org.apache.gravitino.idp.dto.requests.ChangePasswordRequest;
@@ -40,6 +46,7 @@ import org.apache.gravitino.idp.web.IdpManagement;
import org.apache.gravitino.idp.web.IdpOperationType;
import org.apache.gravitino.idp.web.IdpRESTUtils;
import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.utils.PrincipalUtils;
/** REST resource for built-in IdP user management exposed by the {@code
idp-basic} plugin. */
@IdpManagement
@@ -87,8 +94,16 @@ public class IdpUserOperations {
request.getUser());
}
+ /**
+ * Changes a user's password. Self-service changes require {@code
oldPassword}. Service admins may
+ * reset another user without {@code oldPassword} and must not send it.
+ *
+ * @param user the username whose password is changed
+ * @param request the password change request
+ * @return the updated user response
+ */
@PUT
- @Path("{user}")
+ @Path("{user}/password")
@Produces("application/vnd.gravitino.v1+json")
@Timed(name = "change-idp-user-password." +
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
@ResponseMetered(name = "change-idp-user-password", absolute = true)
@@ -97,7 +112,26 @@ public class IdpUserOperations {
httpRequest,
() -> {
request.validate();
- userGroupManager.changePassword(user, request.getPassword());
+ String currentUser = PrincipalUtils.getCurrentUserName();
+ List<String> serviceAdmins =
+ GravitinoEnv.getInstance().config().get(Configs.SERVICE_ADMINS);
+ boolean isServiceAdmin =
+ IdpAuthorizationFilter.isServiceAdmin(serviceAdmins,
currentUser);
+ if (!isServiceAdmin && !user.equals(currentUser)) {
+ throw new ForbiddenException("Only service admins can change
another user's password");
+ }
+ if (user.equals(currentUser)) {
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(request.getOldPassword()),
+ "\"oldPassword\" is required when changing your own password");
+ userGroupManager.changePassword(
+ user, request.getNewPassword(), request.getOldPassword());
+ } else {
+ Preconditions.checkArgument(
+ request.getOldPassword() == null,
+ "\"oldPassword\" is only allowed when changing your own
password");
+ userGroupManager.changePassword(user, request.getNewPassword());
+ }
return IdpRESTUtils.ok(new
IdpUserResponse(userGroupManager.getUser(user).toDTO()));
},
"user",
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
index 2ce53469d0..089db3082e 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/TestIdpUserGroupManager.java
@@ -134,6 +134,22 @@ public class TestIdpUserGroupManager {
NotFoundException.class, () -> manager.changePassword("not-exist",
VALID_PASSWORD));
}
+ @Test
+ public void testChangePasswordWithOldPassword() throws IOException {
+ manager.addUser("testChangeWithOld", VALID_PASSWORD);
+
+ Assertions.assertTrue(
+ manager.changePassword("testChangeWithOld", NEW_VALID_PASSWORD,
VALID_PASSWORD));
+ Assertions.assertEquals(
+ "testChangeWithOld", manager.authenticate("testChangeWithOld",
NEW_VALID_PASSWORD).name());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> manager.changePassword("testChangeWithOld", VALID_PASSWORD,
"wrong-old-password"));
+ Assertions.assertEquals(
+ "testChangeWithOld", manager.authenticate("testChangeWithOld",
NEW_VALID_PASSWORD).name());
+ }
+
@Test
public void testAddGroup() throws IOException {
IdpGroup group = manager.addGroup("testAddGroup");
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
index 1a6b57a0fb..2da1c6a6da 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/dto/requests/TestChangePasswordRequest.java
@@ -7,7 +7,7 @@
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
@@ -37,36 +37,43 @@ public class TestChangePasswordRequest {
JsonUtils.objectMapper().readValue(serJson,
ChangePasswordRequest.class);
Assertions.assertEquals(request, deserRequest);
- Assertions.assertEquals(VALID_PASSWORD, deserRequest.getPassword());
+ Assertions.assertEquals(VALID_PASSWORD, deserRequest.getNewPassword());
+ Assertions.assertNull(deserRequest.getOldPassword());
+ Assertions.assertTrue(serJson.contains("\"newPassword\""));
- // Test with null password
- ChangePasswordRequest request1 = new ChangePasswordRequest();
-
- String serJson1 = JsonUtils.objectMapper().writeValueAsString(request1);
- ChangePasswordRequest deserRequest1 =
- JsonUtils.objectMapper().readValue(serJson1,
ChangePasswordRequest.class);
-
- Assertions.assertEquals(request1, deserRequest1);
- Assertions.assertNull(deserRequest1.getPassword());
+ ChangePasswordRequest withOld = new ChangePasswordRequest(VALID_PASSWORD,
"old_password12");
+ ChangePasswordRequest deserWithOld =
+ JsonUtils.objectMapper()
+ .readValue(
+ JsonUtils.objectMapper().writeValueAsString(withOld),
ChangePasswordRequest.class);
+ Assertions.assertEquals(withOld, deserWithOld);
+ Assertions.assertEquals("old_password12", deserWithOld.getOldPassword());
}
@Test
public void testChangePasswordRequestValidate() {
Assertions.assertDoesNotThrow(() -> new
ChangePasswordRequest(VALID_PASSWORD).validate());
- Assertions.assertThrows(
- IllegalArgumentException.class, () -> new
ChangePasswordRequest().validate());
- Assertions.assertThrows(
- IllegalArgumentException.class, () -> new ChangePasswordRequest("
").validate());
+ Assertions.assertDoesNotThrow(
+ () -> new ChangePasswordRequest(VALID_PASSWORD,
"old_password12").validate());
+ IllegalArgumentException missing =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new
ChangePasswordRequest().validate());
+ Assertions.assertTrue(missing.getMessage().contains("\"newPassword\""));
+ IllegalArgumentException blank =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new ChangePasswordRequest("
").validate());
+ Assertions.assertTrue(blank.getMessage().contains("\"newPassword\""));
Assertions.assertThrows(
IllegalArgumentException.class, () -> new
ChangePasswordRequest("short").validate());
}
@Test
public void testChangePasswordRequestToStringDoesNotExposePassword() {
- String requestString = new
ChangePasswordRequest(VALID_PASSWORD).toString();
+ String requestString = new ChangePasswordRequest(VALID_PASSWORD,
"old_password12").toString();
Assertions.assertFalse(requestString.contains(VALID_PASSWORD));
- Assertions.assertFalse(requestString.contains("password="));
- Assertions.assertFalse(requestString.contains("\"password\""));
+ Assertions.assertFalse(requestString.contains("old_password12"));
+ Assertions.assertFalse(requestString.contains("newPassword="));
+ Assertions.assertFalse(requestString.contains("oldPassword="));
}
}
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
index aaa3d91d5a..6dd886ea35 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/integration/test/IdpRESTApiIT.java
@@ -134,7 +134,49 @@ public class IdpRESTApiIT extends BaseIT {
postUser(USER2, USER_PASSWORD);
assertError(
- 403, get("/idp/users/" + USER2, USER2, USER_PASSWORD),
ErrorConstants.FORBIDDEN_CODE);
+ 403, get("/idp/users/" + USER1, USER2, USER_PASSWORD),
ErrorConstants.FORBIDDEN_CODE);
+ HttpResponse<String> selfGet = get("/idp/users/" + USER2, USER2,
USER_PASSWORD);
+ Assertions.assertEquals(200, selfGet.statusCode(), selfGet.body());
+ IdpUserResponse self =
+ JsonUtils.objectMapper().readValue(selfGet.body(),
IdpUserResponse.class);
+ self.validate();
+ Assertions.assertEquals(USER2, self.getUser().name());
+
+ assertError(
+ 400,
+ put(
+ "/idp/users/" + USER2 + "/password",
+ new ChangePasswordRequest(UPDATED_PASSWORD),
+ USER2,
+ USER_PASSWORD),
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+ assertError(
+ 400,
+ put(
+ "/idp/users/" + USER2 + "/password",
+ new ChangePasswordRequest(UPDATED_PASSWORD, "wrong-password-12"),
+ USER2,
+ USER_PASSWORD),
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+
+ HttpResponse<String> selfUpdate =
+ put(
+ "/idp/users/" + USER2 + "/password",
+ new ChangePasswordRequest(UPDATED_PASSWORD, USER_PASSWORD),
+ USER2,
+ USER_PASSWORD);
+ Assertions.assertEquals(200, selfUpdate.statusCode(), selfUpdate.body());
+ Assertions.assertEquals(200, get("/version", USER2,
UPDATED_PASSWORD).statusCode());
+
+ assertError(
+ 403,
+ put(
+ "/idp/users/" + USER1 + "/password",
+ new ChangePasswordRequest(UPDATED_PASSWORD),
+ USER2,
+ UPDATED_PASSWORD),
+ ErrorConstants.FORBIDDEN_CODE);
+
Assertions.assertTrue(deleteUser(USER2));
}
@@ -171,11 +213,13 @@ public class IdpRESTApiIT extends BaseIT {
ErrorConstants.NOT_FOUND_CODE);
assertError(
404,
- put("/idp/users/" + MISSING_USER, new
ChangePasswordRequest(UPDATED_PASSWORD)),
+ put(
+ "/idp/users/" + MISSING_USER + "/password",
+ new ChangePasswordRequest(UPDATED_PASSWORD)),
ErrorConstants.NOT_FOUND_CODE);
assertError(
400,
- put("/idp/users/" + USER1, new ChangePasswordRequest(" ")),
+ put("/idp/users/" + USER1 + "/password", new ChangePasswordRequest("
")),
ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
Assertions.assertTrue(deleteUser(USER1));
@@ -284,7 +328,7 @@ public class IdpRESTApiIT extends BaseIT {
private static void changePassword(String username, String password) throws
Exception {
HttpResponse<String> response =
- put("/idp/users/" + username, new ChangePasswordRequest(password));
+ put("/idp/users/" + username + "/password", new
ChangePasswordRequest(password));
Assertions.assertEquals(200, response.statusCode(), response.body());
}
@@ -349,8 +393,13 @@ public class IdpRESTApiIT extends BaseIT {
}
private static HttpResponse<String> put(String path, Object body) throws
Exception {
+ return put(path, body, ADMIN, ADMIN_PASSWORD);
+ }
+
+ private static HttpResponse<String> put(
+ String path, Object body, String username, String password) throws
Exception {
return HTTP.send(
- authorized(ADMIN, ADMIN_PASSWORD)
+ authorized(username, password)
.uri(URI.create(apiBase + path))
.header("Content-Type", ACCEPT)
.PUT(jsonBody(body))
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
index 5fb8167357..de0b94a736 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpAuthorizationFilter.java
@@ -20,8 +20,14 @@ package org.apache.gravitino.idp.web.rest;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.util.Arrays;
import java.util.List;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.PathSegment;
+import javax.ws.rs.core.UriInfo;
import org.junit.jupiter.api.Test;
class TestIdpAuthorizationFilter {
@@ -33,4 +39,43 @@ class TestIdpAuthorizationFilter {
assertFalse(IdpAuthorizationFilter.isServiceAdmin(List.of("admin"),
"other"));
assertTrue(IdpAuthorizationFilter.isServiceAdmin(List.of("admin", "ops"),
"admin"));
}
+
+ @Test
+ void testIsSelfUserAccess() {
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp", "users",
"alice"), null));
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(
+ request("DELETE", "idp", "users", "alice"), "alice"));
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp", "users",
"bob"), "alice"));
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(request("GET", "idp", "users",
"bob"), "alice"));
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp",
"groups", "alice"), "alice"));
+ assertTrue(
+ IdpAuthorizationFilter.isSelfUserAccess(request("GET", "idp", "users",
"alice"), "alice"));
+ assertTrue(
+ IdpAuthorizationFilter.isSelfUserAccess(
+ request("PUT", "idp", "users", "alice", "password"), "alice"));
+ assertFalse(
+ IdpAuthorizationFilter.isSelfUserAccess(request("PUT", "idp", "users",
"alice"), "alice"));
+ }
+
+ private static ContainerRequestContext request(String method, String...
segments) {
+ List<PathSegment> pathSegments =
+
Arrays.stream(segments).map(TestIdpAuthorizationFilter::segment).toList();
+ ContainerRequestContext context = mock(ContainerRequestContext.class);
+ UriInfo uriInfo = mock(UriInfo.class);
+ when(context.getMethod()).thenReturn(method);
+ when(context.getUriInfo()).thenReturn(uriInfo);
+ when(uriInfo.getPathSegments()).thenReturn(pathSegments);
+ return context;
+ }
+
+ private static PathSegment segment(String path) {
+ PathSegment pathSegment = mock(PathSegment.class);
+ when(pathSegment.getPath()).thenReturn(path);
+ return pathSegment;
+ }
}
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
index f8ae328fe1..42580f0861 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/rest/TestIdpOperations.java
@@ -34,6 +34,12 @@ import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.Application;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.dto.responses.ErrorConstants;
import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.dto.responses.RemoveResponse;
@@ -53,7 +59,9 @@ import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;
import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -63,6 +71,21 @@ class TestIdpOperations extends JerseyTest {
private static final String VALID_PASSWORD = "Passw0rd-For-User";
private static final IdpUserGroupManager MANAGER =
mock(IdpUserGroupManager.class);
+ private static Config previousConfig;
+
+ @BeforeAll
+ static void installServiceAdminConfig() throws Exception {
+ previousConfig = GravitinoEnv.getInstance().config();
+ Config config = mock(Config.class);
+ when(config.get(Configs.SERVICE_ADMINS)).thenReturn(List.of("admin"));
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true);
+ }
+
+ @AfterAll
+ static void restoreConfig() throws Exception {
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config",
previousConfig, true);
+ }
+
@BeforeEach
void resetManager() {
reset(MANAGER);
@@ -79,6 +102,8 @@ class TestIdpOperations extends JerseyTest {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteUser()).thenReturn(null);
+
when(request.getAttribute(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME))
+ .thenReturn(new UserPrincipal("admin"));
ResourceConfig resourceConfig = new ResourceConfig();
resourceConfig.register(IdpUserOperations.class);
@@ -133,10 +158,39 @@ class TestIdpOperations extends JerseyTest {
when(MANAGER.removeUser("user1")).thenReturn(true);
Assertions.assertEquals(
- "user1", put("/idp/users/user1",
req).readEntity(IdpUserResponse.class).getUser().name());
+ "user1",
+ put("/idp/users/user1/password",
req).readEntity(IdpUserResponse.class).getUser().name());
Assertions.assertTrue(delete("/idp/users/user1").readEntity(RemoveResponse.class).removed());
}
+ @Test
+ void testAdminSelfPasswordChangeRequiresOldPassword() {
+ ChangePasswordRequest missingOld = new
ChangePasswordRequest(VALID_PASSWORD);
+ assertError(
+ Response.Status.BAD_REQUEST,
+ put("/idp/users/admin/password", missingOld),
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+
+ ChangePasswordRequest withOld = new ChangePasswordRequest(VALID_PASSWORD,
"admin-old-pass12");
+ when(MANAGER.changePassword("admin", VALID_PASSWORD,
"admin-old-pass12")).thenReturn(true);
+ when(MANAGER.getUser("admin")).thenReturn(buildUser("admin"));
+ Assertions.assertEquals(
+ "admin",
+ put("/idp/users/admin/password", withOld)
+ .readEntity(IdpUserResponse.class)
+ .getUser()
+ .name());
+ }
+
+ @Test
+ void testAdminResetRejectsOldPassword() {
+ ChangePasswordRequest req = new ChangePasswordRequest(VALID_PASSWORD,
"other-old-pass12");
+ assertError(
+ Response.Status.BAD_REQUEST,
+ put("/idp/users/user1/password", req),
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE);
+ }
+
@Test
void testAddAndGetGroup() throws Exception {
AddGroupRequest req = new AddGroupRequest("group1");