Skip to content

Commit

Permalink
Merge pull request #114 from mapidentity/keycloak-25
Browse files Browse the repository at this point in the history
Support Keycloak 25
  • Loading branch information
cooperlyt authored Aug 9, 2024
2 parents 353095b + e79fec5 commit e565da9
Show file tree
Hide file tree
Showing 47 changed files with 759 additions and 722 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/compile-and-liveness-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
- uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
- shell: bash
run: cd examples && ./docker-build.sh test
- run: cd examples && docker compose --verbose up --build --wait
4 changes: 2 additions & 2 deletions .github/workflows/mavenpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:

steps:
- uses: actions/checkout@v2
- name: Set up JDK 18
- name: Set up JDK 21
uses: actions/setup-java@v1
with:
java-version: 18
java-version: 21
server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
settings-path: ${{ github.workspace }} # location for the settings.xml file

Expand Down
2 changes: 1 addition & 1 deletion jboss-cli/module-add.cli
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# main provider
module add --name=com.googlecode.libphonenumber --resources=libphonenumber-8.13.7.jar
module add --name=keycloak-phone-provider --resources=keycloak-phone-provider.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-common,org.hibernate,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,org.keycloak.keycloak-model-jpa,org.jboss.logging,javax.api,javax.ws.rs.api,javax.transaction.api,javax.persistence.api,org.jboss.resteasy.resteasy-jaxrs,org.apache.httpcomponents,org.apache.commons.lang,javax.xml.bind.api,com.squareup.okhttp3,com.googlecode.libphonenumber
module add --name=keycloak-phone-provider --resources=keycloak-phone-provider.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-common,org.hibernate,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,org.keycloak.keycloak-model-jpa,org.jboss.logging,javax.api,jakarta.ws.rs.api,javax.transaction.api,javax.persistence.api,org.jboss.resteasy.resteasy-jaxrs,org.apache.httpcomponents,org.apache.commons.lang,javax.xml.bind.api,com.squareup.okhttp3,com.googlecode.libphonenumber

# dummy provider
module add --name=keycloak-sms-provider-dummy --resources=keycloak-sms-provider-dummy.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.jboss.logging,keycloak-phone-provider
Expand Down
21 changes: 18 additions & 3 deletions keycloak-phone-provider/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,24 @@
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>8.13.7</version>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>8.13.41</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxrs</artifactId>
<version>3.15.6.Final</version>
</dependency>
</dependencies>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@
import org.keycloak.services.validation.Validation;

import java.util.Optional;
import java.util.regex.Pattern;

public class OptionalUtils {

public static Optional<String> ofEmpty(String str){
public static Optional<String> ofEmpty(String str) {
return Validation.isEmpty(str) ? Optional.empty() : Optional.of(str);
}

public static Optional<String> ofBlank(String str){
public static Optional<String> ofBlank(String str) {
return Validation.isBlank(str) ? Optional.empty() : Optional.of(str).map(String::trim);
}

public static Optional<Boolean> ofTrue(boolean b){
public static Optional<Boolean> ofTrue(boolean b) {
return b ? Optional.of(true) : Optional.empty();
}
}
Original file line number Diff line number Diff line change
@@ -1,143 +1,141 @@
package cc.coopersoft.keycloak.phone;

import cc.coopersoft.common.OptionalUtils;
import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialModel;
import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException;
import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider;
import org.jboss.logging.Logger;
import org.keycloak.credential.CredentialModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import com.google.i18n.phonenumbers.*;
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
import org.keycloak.models.credential.dto.OTPSecretData;
import org.keycloak.services.validation.Validation;
import org.keycloak.util.JsonSerialization;

import javax.validation.constraints.NotNull;
import java.io.IOException;
import jakarta.validation.constraints.NotNull;
import java.util.*;
import java.util.function.Predicate;
import java.util.regex.Pattern;

public class Utils {
private static final Logger logger = Logger.getLogger(Utils.class);

public static Optional<UserModel> findUserByPhone(KeycloakSession session,RealmModel realm, String phoneNumber){
public static Optional<UserModel> findUserByPhone(KeycloakSession session, RealmModel realm, String phoneNumber) {

var userProvider = session.users();
Set<String> numbers = new HashSet<>();
numbers.add(phoneNumber);

if (session.getProvider(PhoneProvider.class).compatibleMode()){
if (session.getProvider(PhoneProvider.class).compatibleMode()) {
var phoneNumberUtil = PhoneNumberUtil.getInstance();
try {
var parsedNumber = phoneNumberUtil.parse(phoneNumber, defaultRegion(session));
if (parsedNumber.hasNationalNumber()){
numbers.add(String.valueOf(parsedNumber.getNationalNumber())) ;
if (parsedNumber.hasNationalNumber()) {
numbers.add(String.valueOf(parsedNumber.getNationalNumber()));
}
for (PhoneNumberFormat format : PhoneNumberFormat.values()) {
numbers.add(phoneNumberUtil.format(parsedNumber, format));
}
}catch (NumberParseException e){
logger.warn(String.format("%s is not a valid phone number!",phoneNumber),e);
} catch (NumberParseException e) {
logger.warn(String.format("%s is not a valid phone number!", phoneNumber), e);
}
}


return numbers.stream().flatMap(number -> userProvider
.searchForUserByUserAttributeStream(realm,"phoneNumber", number))
.max((u1, u2) -> {
var result = comparatorAttributesAnyMatch(u1,u2,"phoneNumberVerified","true"::equals);
if (result == 0){
result = comparatorAttributesAnyMatch(u1,u2,"phoneNumber", number -> number.startsWith("+"));
}
return result;
});
.searchForUserByUserAttributeStream(realm, "phoneNumber", number))
.max((u1, u2) -> {
var result = comparatorAttributesAnyMatch(u1, u2, "phoneNumberVerified", "true"::equals);
if (result == 0) {
result = comparatorAttributesAnyMatch(u1, u2, "phoneNumber", number -> number.startsWith("+"));
}
return result;
});

}

// public static Optional<UserModel> findUserByPhone(UserProvider userProvider, RealmModel realm, String phoneNumber, String notIs){
// return userProvider
// .searchForUserByUserAttributeStream(realm, "phoneNumber", phoneNumber)
// .filter(u -> !u.getId().equals(notIs))
// .max(comparatorUser());
// }
// public static Optional<UserModel> findUserByPhone(UserProvider userProvider,
// RealmModel realm, String phoneNumber, String notIs){
// return userProvider
// .searchForUserByUserAttributeStream(realm, "phoneNumber", phoneNumber)
// .filter(u -> !u.getId().equals(notIs))
// .max(comparatorUser());
// }

private static int comparatorAttributesAnyMatch(UserModel user1, UserModel user2,
String attribute, Predicate<? super String> predicate){
String attribute, Predicate<? super String> predicate) {
return Boolean.compare(user1.getAttributeStream(attribute).anyMatch(predicate),
user2.getAttributeStream(attribute).anyMatch(predicate));
}

private static Optional<String> localeToCountry(String locale){
private static Optional<String> localeToCountry(String locale) {
return OptionalUtils.ofBlank(locale).flatMap(l -> {
Pattern countryRegx = Pattern.compile("[^a-z]*\\-?([A-Z]{2,3})");
return Optional.of(countryRegx.matcher(l))
.flatMap(m -> m.find() ? OptionalUtils.ofBlank(m.group(1)) : Optional.empty());
.flatMap(m -> m.find() ? OptionalUtils.ofBlank(m.group(1)) : Optional.empty());
});
}


private static String defaultRegion(KeycloakSession session){
private static String defaultRegion(KeycloakSession session) {
var defaultRegion = session.getProvider(PhoneProvider.class).defaultPhoneRegion();
return defaultRegion.orElseGet(() -> localeToCountry(session.getContext().getRealm().getDefaultLocale()).orElse(null));
return defaultRegion
.orElseGet(() -> localeToCountry(session.getContext().getRealm().getDefaultLocale()).orElse(null));
}

/**
* Parses a phone number with google's libphonenumber and then outputs it's
* international canonical form
*
*/
public static String canonicalizePhoneNumber(KeycloakSession session,@NotNull String phoneNumber) throws PhoneNumberInvalidException {
* Parses a phone number with google's libphonenumber and then outputs it's
* international canonical form
*
*/
public static String canonicalizePhoneNumber(KeycloakSession session, @NotNull String phoneNumber)
throws PhoneNumberInvalidException {
var provider = session.getProvider(PhoneProvider.class);


var phoneNumberUtil = PhoneNumberUtil.getInstance();
var resultPhoneNumber = phoneNumber.trim();
var defaultRegion = defaultRegion(session);
logger.info(String.format("default region '%s' will be used",defaultRegion));
logger.info(String.format("default region '%s' will be used", defaultRegion));
try {
var parsedNumber = phoneNumberUtil.parse(resultPhoneNumber, defaultRegion);
if (provider.validPhoneNumber() && !phoneNumberUtil.isValidNumber(parsedNumber)) {
logger.info(String.format("Phone number [%s] Valid fail with google's libphonenumber",resultPhoneNumber));
logger.info(
String.format("Phone number [%s] Valid fail with google's libphonenumber", resultPhoneNumber));
throw new PhoneNumberInvalidException(PhoneNumberInvalidException.ErrorType.VALID_FAIL,
String.format("Phone number [%s] Valid fail with google's libphonenumber",resultPhoneNumber));
String.format("Phone number [%s] Valid fail with google's libphonenumber", resultPhoneNumber));
}

var canonicalizeFormat = provider.canonicalizePhoneNumber();
try {
resultPhoneNumber = canonicalizeFormat
.map(PhoneNumberFormat::valueOf)
.map(format -> phoneNumberUtil.format(parsedNumber, format))
.orElse(resultPhoneNumber);
}catch (RuntimeException e){
logger.warn(String.format("canonicalize format param error! '%s' is not in supported list: %s, E164 Will be used.",
Arrays.toString(PhoneNumberFormat.values()),
canonicalizeFormat.orElse("")),e);
.map(PhoneNumberFormat::valueOf)
.map(format -> phoneNumberUtil.format(parsedNumber, format))
.orElse(resultPhoneNumber);
} catch (RuntimeException e) {
logger.warn(String.format(
"canonicalize format param error! '%s' is not in supported list: %s, E164 Will be used.",
Arrays.toString(PhoneNumberFormat.values()),
canonicalizeFormat.orElse("")), e);
resultPhoneNumber = phoneNumberUtil.format(parsedNumber, PhoneNumberFormat.E164);
}

var phoneNumberRegex = provider.phoneNumberRegex();
if (!phoneNumberRegex.map(resultPhoneNumber::matches).orElse(true)){
logger.info(String.format("Phone number [%s] not match regex '%s'",resultPhoneNumber, phoneNumberRegex.orElse("")));
if (!phoneNumberRegex.map(resultPhoneNumber::matches).orElse(true)) {
logger.info(String.format("Phone number [%s] not match regex '%s'", resultPhoneNumber,
phoneNumberRegex.orElse("")));
throw new PhoneNumberInvalidException(PhoneNumberInvalidException.ErrorType.NOT_SUPPORTED,
String.format("Phone number [%s] not match regex '%s'",resultPhoneNumber, phoneNumberRegex.orElse("")));
String.format("Phone number [%s] not match regex '%s'", resultPhoneNumber,
phoneNumberRegex.orElse("")));
}
return resultPhoneNumber;
}catch (NumberParseException e){
} catch (NumberParseException e) {
logger.info(e);
throw new PhoneNumberInvalidException(e);
}
}

public static boolean isDuplicatePhoneAllowed(KeycloakSession session){
public static boolean isDuplicatePhoneAllowed(KeycloakSession session) {
return session.getProvider(PhoneProvider.class).isDuplicatePhoneAllowed();
}

public static int getOtpExpires(KeycloakSession session){
public static int getOtpExpires(KeycloakSession session) {
return session.getProvider(PhoneProvider.class).otpExpires();
}

Expand Down
Loading

0 comments on commit e565da9

Please sign in to comment.