This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new d9e092ad00 [#10961] feat(authn): Add password hashing support (#10968)
d9e092ad00 is described below
commit d9e092ad0007ff6b35cbaec07668fe93ea3bdb85
Author: MaSai <[email protected]>
AuthorDate: Fri May 8 20:03:13 2026 +0800
[#10961] feat(authn): Add password hashing support (#10968)
### What changes were proposed in this pull request?
This PR adds password hashing support for the local authentication
module.
The changes include:
- adding the `bcprov-jdk18on` dependency for the `authenticator-basic`
module
- introducing the `PasswordHasher` abstraction
- adding `Argon2idDefaults` to centralize the built-in Argon2id defaults
- adding `Argon2idPasswordHasher` as the built-in password hashing
implementation
- adding `PasswordHasherFactory` to create the built-in hasher
- generating and verifying Argon2id PHC-style hashes with the built-in
cost settings
- updating binary license/notice inventories for the new runtime
dependency
### Why are the changes needed?
Local authentication needs a secure password hashing mechanism before
user credentials can be persisted and verified safely. This PR
implements the password hashing part of the design by using Argon2id as
the built-in algorithm and stores hashes in PHC format so the encoded
parameters travel with the stored hash.
Fix: #10961
### Does this PR introduce _any_ user-facing change?
Yes.
- local authentication password hashing support is added
- Argon2id becomes the built-in password hashing algorithm for the local
authenticator
- stored password hashes use PHC-style Argon2id strings
### How was this patch tested?
- added and updated `TestArgon2idPasswordHasher`
- verified PHC output structure and parameters, including `v=19` and
`m=65536,t=3,p=1`
- verified malformed PHC strings, invalid Base64 input, and unsupported
parameter combinations are rejected deterministically
- ran:
- `./gradlew :authenticators:authenticator-basic:test --tests
org.apache.gravitino.auth.local.password.TestArgon2idPasswordHasher
-PskipITs -PskipDockerTests=true`
- `./gradlew :authenticators:authenticator-basic:build -PskipITs
-PskipDockerTests=true`
---------
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
LICENSE.bin | 1 +
NOTICE.bin | 3 +
build.gradle.kts | 1 +
design-docs/gravitino-local-authentication.md | 8 +-
gradle/libs.versions.toml | 2 +
licenses/bouncycastle.txt | 16 ++
plugins/idp-basic/build.gradle.kts | 39 ++++
.../idp/basic/password/Argon2idDefaults.java | 36 ++++
.../idp/basic/password/Argon2idPasswordHasher.java | 234 +++++++++++++++++++++
.../idp/basic/password/PasswordHasher.java | 30 +++
.../idp/basic/password/PasswordHasherFactory.java | 31 +++
.../basic/password/TestArgon2idPasswordHasher.java | 155 ++++++++++++++
settings.gradle.kts | 1 +
13 files changed, 553 insertions(+), 4 deletions(-)
diff --git a/LICENSE.bin b/LICENSE.bin
index 0350cd952d..0f958ed614 100644
--- a/LICENSE.bin
+++ b/LICENSE.bin
@@ -327,6 +327,7 @@
Apache Kyuubi
Apache Ranger
Apache Ranger intg
+ Bouncy Castle
Jackson JSON processor
DataNucleus
Modernizer Maven Plugin
diff --git a/NOTICE.bin b/NOTICE.bin
index 6df85783e4..5b24f40fbf 100644
--- a/NOTICE.bin
+++ b/NOTICE.bin
@@ -241,6 +241,9 @@ Joda.org (https://www.joda.org/).
Apache log4j
Copyright 2010 The Apache Software Foundation
+Bouncy Castle
+Copyright (c) 2000 - 2023 The Legion of the Bouncy Castle Inc.
+
Byte Buddy
Copyright 2014 - Present Rafael Winterhalter
diff --git a/build.gradle.kts b/build.gradle.kts
index 8245c405b3..06113d3940 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1153,6 +1153,7 @@ tasks {
it.name != "web" &&
it.name != "web-v2" &&
it.parent?.name != "bundles" &&
+ it.parent?.name != "plugins" &&
it.parent?.name != "maintenance" &&
it.name != "mcp-server"
) {
diff --git a/design-docs/gravitino-local-authentication.md
b/design-docs/gravitino-local-authentication.md
index edede52d2d..fe80d10e2f 100644
--- a/design-docs/gravitino-local-authentication.md
+++ b/design-docs/gravitino-local-authentication.md
@@ -109,7 +109,7 @@ The local authentication feature should be implemented as
an independent Graviti
The recommended module name is:
-- `authenticators:authenticator-basic`
+- `plugins:idp-basic`
This naming keeps the capability grouping explicit while aligning the module
name with the
configured authenticator type. Although the module also includes the broader
built-in
@@ -122,7 +122,7 @@ authenticator, including:
- and the local authentication management API wiring.
The local authentication-specific logic should be owned by
-`authenticators:authenticator-basic`, including storage access, authenticator
logic, service admin
+`plugins:idp-basic`, including storage access, authenticator logic, service
admin
initialization logic, password hashing, and management API exposure, so that
the feature has a
clear packaging boundary and can evolve independently.
@@ -741,7 +741,7 @@ curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
| Phase | Work Item | Module / Files | Notes |
|---|---|---|---|
-| 1 | Authenticator module wiring | `settings.gradle.kts`,
`server/build.gradle.kts`, `authenticators:authenticator-basic` | Add the new
module and make the server load it when `gravitino.authenticators=basic`. |
+| 1 | Authenticator module wiring | `settings.gradle.kts`,
`server/build.gradle.kts`, `plugins:idp-basic` | Add the new module and make
the server load it when `gravitino.authenticators=basic`. |
| 2 | Password hashing support | `PasswordHasher`, `Argon2idPasswordHasher`,
related tests | Use Argon2id as the only supported password hashing algorithm
and store PHC-style hash strings. |
| 3 | IdP metadata schema | JDBC schema files, mapper definitions, store layer
| Create `idp_user_meta`, `idp_group_meta`, and `idp_group_user_rel` with
soft-delete support. |
| 4 | Service admin initialization | startup initialization logic, validation
logic | Validate `GRAVITINO_INITIAL_ADMIN_PASSWORD`, initialize missing
configured service admins during startup, and fail startup when required
credentials are absent. |
@@ -754,7 +754,7 @@ curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
| Area | Checklist |
|---|---|
-| Module wiring | The design, module name, and server wiring all consistently
use `authenticators:authenticator-basic`, while the authenticator mode remains
`basic`. |
+| Module wiring | The design, module name, and server wiring all consistently
use `plugins:idp-basic`, while the authenticator mode remains `basic`. |
| Configuration | All examples use `gravitino.authenticators=basic`, and no
obsolete configuration keys remain in the document. |
| Schema design | The document consistently uses `idp_user_meta`,
`idp_group_meta`, and `idp_group_user_rel`, and the soft-delete lifecycle is
clearly described. |
| Security constraints | The document states that passwords are never stored
in plaintext, Basic authentication should be used only over HTTPS, and
initialization must enforce password policy. |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 685be80560..f5d0b34629 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -95,6 +95,7 @@ kafka = "3.4.0"
curator = "2.12.0"
awaitility = "4.2.1"
servlet = "3.1.0"
+bouncycastle = "1.84"
jodd = "3.5.2"
flink = "1.18.0"
flinkjdbc = "3.2.0-1.18"
@@ -193,6 +194,7 @@ jersey-test-framework-provider-jetty = { group =
"org.glassfish.jersey.test-fram
jodd-core = { group = "org.jodd", name = "jodd-core", version.ref = "jodd" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref =
"mockito" }
mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref
= "mockito" }
+bcprov-jdk18on = { group = "org.bouncycastle", name = "bcprov-jdk18on",
version.ref = "bouncycastle" }
hive2-metastore = { group = "org.apache.hive", name = "hive-metastore",
version.ref = "hive2"}
hive2-exec = { group = "org.apache.hive", name = "hive-exec", version.ref =
"hive2"}
hive2-common = { group = "org.apache.hive", name = "hive-common", version.ref
= "hive2"}
diff --git a/licenses/bouncycastle.txt b/licenses/bouncycastle.txt
new file mode 100644
index 0000000000..c087fd39fb
--- /dev/null
+++ b/licenses/bouncycastle.txt
@@ -0,0 +1,16 @@
+Copyright (c) 2000 - 2023 The Legion of the Bouncy Castle Inc.
(https://www.bouncycastle.org)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
of this software
+and associated documentation files (the "Software"), to deal in the Software
without restriction,
+including without limitation the rights to use, copy, modify, merge, publish,
distribute,
+sublicense, and/or sell copies of the Software, and to permit persons to whom
the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
copies or
+substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT
+OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
diff --git a/plugins/idp-basic/build.gradle.kts
b/plugins/idp-basic/build.gradle.kts
new file mode 100644
index 0000000000..e10209214c
--- /dev/null
+++ b/plugins/idp-basic/build.gradle.kts
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ `maven-publish`
+ id("java")
+ id("idea")
+}
+
+dependencies {
+ implementation(libs.bcprov.jdk18on)
+ implementation(libs.commons.lang3)
+ implementation(libs.guava)
+ testImplementation(libs.junit.jupiter.api)
+ testRuntimeOnly(libs.junit.jupiter.engine)
+}
+
+tasks {
+ test {
+ environment("GRAVITINO_HOME", rootDir.path)
+ environment("GRAVITINO_TEST", "true")
+ }
+}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idDefaults.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idDefaults.java
new file mode 100644
index 0000000000..1ccb61bd96
--- /dev/null
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idDefaults.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.basic.password;
+
+import org.bouncycastle.crypto.params.Argon2Parameters;
+
+/** Default parameters for the built-in Argon2id password hasher. */
+public final class Argon2idDefaults {
+
+ public static final int DEFAULT_VERSION = Argon2Parameters.ARGON2_VERSION_13;
+ public static final int DEFAULT_TYPE = Argon2Parameters.ARGON2_id;
+ public static final int DEFAULT_HASH_LENGTH = 32;
+ public static final int DEFAULT_MEMORY_KB = 1 << 16;
+ public static final int DEFAULT_ITERATIONS = 3;
+ public static final int DEFAULT_PARALLELISM = 1;
+ public static final int DEFAULT_SALT_LENGTH = 16;
+
+ private Argon2idDefaults() {}
+}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idPasswordHasher.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idPasswordHasher.java
new file mode 100644
index 0000000000..98e895e07c
--- /dev/null
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/Argon2idPasswordHasher.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.basic.password;
+
+import com.google.common.base.Preconditions;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+import org.apache.commons.lang3.StringUtils;
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
+import org.bouncycastle.crypto.params.Argon2Parameters;
+import org.bouncycastle.util.Arrays;
+
+/** Argon2id-based password hasher. */
+public class Argon2idPasswordHasher implements PasswordHasher {
+
+ private static final String PHC_PREFIX = "$argon2id$";
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ @Override
+ public String hash(String plainPassword) {
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(plainPassword), "Plain password must not be
blank");
+
+ byte[] salt = new byte[Argon2idDefaults.DEFAULT_SALT_LENGTH];
+ SECURE_RANDOM.nextBytes(salt);
+ byte[] passwordBytes = plainPassword.getBytes(StandardCharsets.UTF_8);
+ byte[] hash = new byte[Argon2idDefaults.DEFAULT_HASH_LENGTH];
+ try {
+ generateHash(
+ passwordBytes,
+ salt,
+ Argon2idDefaults.DEFAULT_ITERATIONS,
+ Argon2idDefaults.DEFAULT_MEMORY_KB,
+ Argon2idDefaults.DEFAULT_PARALLELISM,
+ Argon2idDefaults.DEFAULT_VERSION,
+ hash);
+ return toPhcString(
+ salt,
+ hash,
+ Argon2idDefaults.DEFAULT_ITERATIONS,
+ Argon2idDefaults.DEFAULT_MEMORY_KB,
+ Argon2idDefaults.DEFAULT_PARALLELISM,
+ Argon2idDefaults.DEFAULT_VERSION);
+ } finally {
+ Arrays.clear(passwordBytes);
+ Arrays.clear(salt);
+ Arrays.clear(hash);
+ }
+ }
+
+ @Override
+ public boolean verify(String plainPassword, String hashedPassword) {
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(plainPassword), "Plain password must not be
blank");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(hashedPassword), "Hashed password must not be
blank");
+
+ ParsedHash parsedHash = parse(hashedPassword);
+ byte[] passwordBytes = plainPassword.getBytes(StandardCharsets.UTF_8);
+ byte[] actualHash = new byte[parsedHash.hash.length];
+ try {
+ generateHash(
+ passwordBytes,
+ parsedHash.salt,
+ parsedHash.iterations,
+ parsedHash.memoryKb,
+ parsedHash.parallelism,
+ parsedHash.version,
+ actualHash);
+ return MessageDigest.isEqual(actualHash, parsedHash.hash);
+ } finally {
+ Arrays.clear(passwordBytes);
+ Arrays.clear(actualHash);
+ Arrays.clear(parsedHash.salt);
+ Arrays.clear(parsedHash.hash);
+ }
+ }
+
+ private static void generateHash(
+ byte[] passwordBytes,
+ byte[] salt,
+ int iterations,
+ int memoryKb,
+ int parallelism,
+ int version,
+ byte[] output) {
+ Argon2Parameters parameters =
+ new Argon2Parameters.Builder(Argon2idDefaults.DEFAULT_TYPE)
+ .withVersion(version)
+ .withIterations(iterations)
+ .withMemoryAsKB(memoryKb)
+ .withParallelism(parallelism)
+ .withSalt(salt)
+ .build();
+ Argon2BytesGenerator generator = new Argon2BytesGenerator();
+ generator.init(parameters);
+ generator.generateBytes(passwordBytes, output);
+ }
+
+ private static String toPhcString(
+ byte[] salt, byte[] hash, int iterations, int memoryKb, int parallelism,
int version) {
+ Base64.Encoder encoder = Base64.getEncoder().withoutPadding();
+ return PHC_PREFIX
+ + "v="
+ + version
+ + "$m="
+ + memoryKb
+ + ",t="
+ + iterations
+ + ",p="
+ + parallelism
+ + "$"
+ + encoder.encodeToString(salt)
+ + "$"
+ + encoder.encodeToString(hash);
+ }
+
+ private static ParsedHash parse(String hashedPassword) {
+ Preconditions.checkArgument(hashedPassword.startsWith(PHC_PREFIX),
invalidHashFormatMessage());
+ String[] parts = hashedPassword.split("\\$");
+ Preconditions.checkArgument(parts.length == 6, invalidHashFormatMessage());
+ Preconditions.checkArgument("argon2id".equals(parts[1]),
invalidHashFormatMessage());
+ Preconditions.checkArgument(parts[2].startsWith("v="),
invalidHashFormatMessage());
+ Preconditions.checkArgument(parts[3].startsWith("m="),
invalidHashFormatMessage());
+
+ String[] parameterParts = parts[3].split(",");
+ Preconditions.checkArgument(parameterParts.length == 3,
invalidHashFormatMessage());
+ Preconditions.checkArgument(parameterParts[1].startsWith("t="),
invalidHashFormatMessage());
+ Preconditions.checkArgument(parameterParts[2].startsWith("p="),
invalidHashFormatMessage());
+
+ ParsedHash parsedHash =
+ new ParsedHash(
+ parseInteger(parts[2].substring(2)),
+ parseInteger(parameterParts[0].substring(2)),
+ parseInteger(parameterParts[1].substring(2)),
+ parseInteger(parameterParts[2].substring(2)),
+ decodeBase64(parts[4], Argon2idDefaults.DEFAULT_SALT_LENGTH),
+ decodeBase64(parts[5], Argon2idDefaults.DEFAULT_HASH_LENGTH));
+ validateSupportedParameters(parsedHash);
+ return parsedHash;
+ }
+
+ private static byte[] decodeBase64(String value, int maxDecodedLength) {
+ Preconditions.checkArgument(
+ value.length() <= maxEncodedLength(maxDecodedLength),
invalidHashFormatMessage());
+ int remainder = value.length() % 4;
+ Preconditions.checkArgument(remainder != 1, invalidHashFormatMessage());
+ String paddedValue = remainder == 0 ? value : value + "====".substring(0,
4 - remainder);
+ try {
+ return Base64.getDecoder().decode(paddedValue);
+ } catch (IllegalArgumentException e) {
+ throw invalidHashFormat();
+ }
+ }
+
+ private static int maxEncodedLength(int decodedLength) {
+ int fullGroups = decodedLength / 3;
+ int remainder = decodedLength % 3;
+ return fullGroups * 4 + (remainder == 0 ? 0 : remainder + 1);
+ }
+
+ private static int parseInteger(String value) {
+ try {
+ return Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ throw invalidHashFormat();
+ }
+ }
+
+ private static void validateSupportedParameters(ParsedHash parsedHash) {
+ Preconditions.checkArgument(
+ parsedHash.version == Argon2idDefaults.DEFAULT_VERSION,
+ "Unsupported Argon2id hash parameters");
+ Preconditions.checkArgument(
+ parsedHash.memoryKb == Argon2idDefaults.DEFAULT_MEMORY_KB,
+ "Unsupported Argon2id hash parameters");
+ Preconditions.checkArgument(
+ parsedHash.iterations == Argon2idDefaults.DEFAULT_ITERATIONS,
+ "Unsupported Argon2id hash parameters");
+ Preconditions.checkArgument(
+ parsedHash.parallelism == Argon2idDefaults.DEFAULT_PARALLELISM,
+ "Unsupported Argon2id hash parameters");
+ Preconditions.checkArgument(
+ parsedHash.salt.length == Argon2idDefaults.DEFAULT_SALT_LENGTH,
invalidHashFormatMessage());
+ Preconditions.checkArgument(
+ parsedHash.hash.length == Argon2idDefaults.DEFAULT_HASH_LENGTH,
invalidHashFormatMessage());
+ }
+
+ private static IllegalArgumentException invalidHashFormat() {
+ return new IllegalArgumentException(invalidHashFormatMessage());
+ }
+
+ private static String invalidHashFormatMessage() {
+ return "Invalid Argon2id hash format";
+ }
+
+ private static class ParsedHash {
+ private final int version;
+ private final int memoryKb;
+ private final int iterations;
+ private final int parallelism;
+ private final byte[] salt;
+ private final byte[] hash;
+
+ private ParsedHash(
+ int version, int memoryKb, int iterations, int parallelism, byte[]
salt, byte[] hash) {
+ this.version = version;
+ this.memoryKb = memoryKb;
+ this.iterations = iterations;
+ this.parallelism = parallelism;
+ this.salt = salt;
+ this.hash = hash;
+ }
+ }
+}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasher.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasher.java
new file mode 100644
index 0000000000..4635d87207
--- /dev/null
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasher.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.basic.password;
+
+/** Password hasher abstraction for local authentication. */
+public interface PasswordHasher {
+
+ /** Hash a plain text password for persistence. */
+ String hash(String plainPassword);
+
+ /** Verify a plain text password against a stored hash. */
+ boolean verify(String plainPassword, String hashedPassword);
+}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasherFactory.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasherFactory.java
new file mode 100644
index 0000000000..25c67e9bd6
--- /dev/null
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/basic/password/PasswordHasherFactory.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.basic.password;
+
+/** Factory for password hasher implementations. */
+public final class PasswordHasherFactory {
+
+ private PasswordHasherFactory() {}
+
+ /** Create the built-in password hasher. */
+ public static PasswordHasher create() {
+ return new Argon2idPasswordHasher();
+ }
+}
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/basic/password/TestArgon2idPasswordHasher.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/basic/password/TestArgon2idPasswordHasher.java
new file mode 100644
index 0000000000..3f83204f8b
--- /dev/null
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/basic/password/TestArgon2idPasswordHasher.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.idp.basic.password;
+
+import java.util.Base64;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestArgon2idPasswordHasher {
+
+ private final PasswordHasher passwordHasher = new Argon2idPasswordHasher();
+
+ @Test
+ public void testHashProducesArgon2idPhcString() {
+ String hashedPassword = passwordHasher.hash("test-password");
+
+ String[] parts = hashedPassword.split("\\$");
+
+ Assertions.assertEquals(6, parts.length);
+ Assertions.assertEquals("", parts[0]);
+ Assertions.assertEquals("argon2id", parts[1]);
+ Assertions.assertEquals("v=" + Argon2idDefaults.DEFAULT_VERSION, parts[2]);
+ Assertions.assertEquals(
+ "m="
+ + Argon2idDefaults.DEFAULT_MEMORY_KB
+ + ",t="
+ + Argon2idDefaults.DEFAULT_ITERATIONS
+ + ",p="
+ + Argon2idDefaults.DEFAULT_PARALLELISM,
+ parts[3]);
+
+ byte[] salt = decodeBase64(parts[4]);
+ byte[] hash = decodeBase64(parts[5]);
+ Assertions.assertEquals(Argon2idDefaults.DEFAULT_SALT_LENGTH, salt.length);
+ Assertions.assertEquals(Argon2idDefaults.DEFAULT_HASH_LENGTH, hash.length);
+ }
+
+ @Test
+ public void testVerifyMatchesExpectedPassword() {
+ String hashedPassword = passwordHasher.hash("test-password");
+
+ Assertions.assertTrue(passwordHasher.verify("test-password",
hashedPassword));
+ Assertions.assertFalse(passwordHasher.verify("wrong-password",
hashedPassword));
+ }
+
+ @Test
+ public void testFactoryCreatesArgon2idHasher() {
+ Assertions.assertTrue(PasswordHasherFactory.create() instanceof
Argon2idPasswordHasher);
+ }
+
+ @Test
+ public void testHashRejectsBlankPassword() {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
passwordHasher.hash(" "));
+
+ Assertions.assertEquals("Plain password must not be blank",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsBlankPlainPassword() {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> passwordHasher.verify(" ",
passwordHasher.hash("test-password")));
+
+ Assertions.assertEquals("Plain password must not be blank",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsBlankHashedPassword() {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
passwordHasher.verify("test-password", " "));
+
+ Assertions.assertEquals("Hashed password must not be blank",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsMalformedPhcString() {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> passwordHasher.verify("test-password",
"$argon2id$v=19$m=65536,x=3,p=1$abc$abc"));
+
+ Assertions.assertEquals("Invalid Argon2id hash format",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsOversizedBase64PhcString() {
+ String oversizedBase64 = "A".repeat(100);
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ passwordHasher.verify(
+ "test-password",
+ "$argon2id$v=19$m=65536,t=3,p=1$" + oversizedBase64 + "$"
+ oversizedBase64));
+
+ Assertions.assertEquals("Invalid Argon2id hash format",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsInvalidBase64PhcString() {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> passwordHasher.verify("test-password",
"$argon2id$v=19$m=65536,t=3,p=1$a$abc"));
+
+ Assertions.assertEquals("Invalid Argon2id hash format",
exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyRejectsUnexpectedArgon2CostParameters() {
+ String hashedPassword = passwordHasher.hash("test-password");
+ String unsupportedHash =
+ hashedPassword.replace(
+ "m="
+ + Argon2idDefaults.DEFAULT_MEMORY_KB
+ + ",t="
+ + Argon2idDefaults.DEFAULT_ITERATIONS
+ + ",p="
+ + Argon2idDefaults.DEFAULT_PARALLELISM,
+ "m=131072,t=3,p=1");
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> passwordHasher.verify("test-password", unsupportedHash));
+
+ Assertions.assertEquals("Unsupported Argon2id hash parameters",
exception.getMessage());
+ }
+
+ private static byte[] decodeBase64(String value) {
+ int remainder = value.length() % 4;
+ String paddedValue = remainder == 0 ? value : value + "====".substring(0,
4 - remainder);
+ return Base64.getDecoder().decode(paddedValue);
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c7a806eafd..22c5e22491 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -110,6 +110,7 @@ include(":bundles:azure", ":bundles:azure-bundle",
":bundles:iceberg-azure-bundl
include(":catalogs:hadoop-common")
include(":lineage")
include(":mcp-server")
+include(":plugins:idp-basic")
include(
":maintenance:optimizer-api",
":maintenance:updaters",