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",

Reply via email to