From 0305b41d765a168d31b04d6f10938680f64c10cf Mon Sep 17 00:00:00 2001 From: Louis Schmieder Date: Thu, 7 Mar 2024 12:51:53 +0100 Subject: [PATCH] Initialize project (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init project * make provider detectable by keycloak * add pipeline * add run configurations for intellij * add email templates & translated messages * add theme properties to find email.ftl & use network aliases for keycloak and mailhog * - use log.debug to log auth events - add readme * add .editorconfig and format files * add new line in messages_de.properties * add tfl to editorconfig * add new line to theme.properties * Update README.md Co-authored-by: Michael Müller <32774798+mitch-mueller@users.noreply.github.com> --------- Co-authored-by: Michael Müller <32774798+mitch-mueller@users.noreply.github.com> --- .editorconfig | 16 +++ .github/workflows/maven.yml | 21 +++ .gitignore | 84 +++++++++++ .idea/fileTemplates/includes/Copyright.java | 3 + .../internal/AnnotationType.java | 5 + .idea/fileTemplates/internal/Class.java | 5 + .idea/fileTemplates/internal/Enum.java | 5 + .idea/fileTemplates/internal/Interface.java | 5 + .idea/fileTemplates/internal/Record.java | 5 + .run/Build keycloak 2fa email.run.xml | 43 ++++++ .run/Deploy local keycloak.run.xml | 15 ++ README.md | 76 ++++++++++ docker-compose.yml | 32 +++++ docs/auth-flow.png | Bin 0 -> 33608 bytes pom.xml | 60 ++++++++ .../keycloak/email2fa/Auth.java | 134 ++++++++++++++++++ .../keycloak/email2fa/AuthChallenges.java | 62 ++++++++ .../keycloak/email2fa/AuthCodeConfig.java | 28 ++++ .../keycloak/email2fa/AuthCodeModel.java | 116 +++++++++++++++ .../keycloak/email2fa/AuthFactory.java | 123 ++++++++++++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + .../messages/messages_de.properties | 12 ++ .../messages/messages_en.properties | 12 ++ .../theme-resources/templates/email-login.ftl | 23 +++ .../theme-resources/templates/html/email.ftl | 11 ++ .../theme-resources/templates/text/email.ftl | 9 ++ .../theme-resources/theme.properties | 2 + 27 files changed, 908 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/maven.yml create mode 100644 .gitignore create mode 100644 .idea/fileTemplates/includes/Copyright.java create mode 100644 .idea/fileTemplates/internal/AnnotationType.java create mode 100644 .idea/fileTemplates/internal/Class.java create mode 100644 .idea/fileTemplates/internal/Enum.java create mode 100644 .idea/fileTemplates/internal/Interface.java create mode 100644 .idea/fileTemplates/internal/Record.java create mode 100644 .run/Build keycloak 2fa email.run.xml create mode 100644 .run/Deploy local keycloak.run.xml create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/auth-flow.png create mode 100644 pom.xml create mode 100644 src/main/java/com/mt_itsolutions/keycloak/email2fa/Auth.java create mode 100644 src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthChallenges.java create mode 100644 src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeConfig.java create mode 100644 src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeModel.java create mode 100644 src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthFactory.java create mode 100644 src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory create mode 100644 src/main/resources/theme-resources/messages/messages_de.properties create mode 100644 src/main/resources/theme-resources/messages/messages_en.properties create mode 100644 src/main/resources/theme-resources/templates/email-login.ftl create mode 100644 src/main/resources/theme-resources/templates/html/email.ftl create mode 100644 src/main/resources/theme-resources/templates/text/email.ftl create mode 100644 src/main/resources/theme-resources/theme.properties diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e2f891e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[{*.java,*.xml,*.tfl}] +indent_size = 4 +indent_style = tab +max_line_length = 120 +tab_width = 4 + +[*.yml] +indent_size = 2 +indent_style = space diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..05920e8 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,21 @@ +name: Maven CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package -f pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8513cd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,84 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +!.idea/ +.idea/* +!.idea/fileTemplates +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store diff --git a/.idea/fileTemplates/includes/Copyright.java b/.idea/fileTemplates/includes/Copyright.java new file mode 100644 index 0000000..fe193e0 --- /dev/null +++ b/.idea/fileTemplates/includes/Copyright.java @@ -0,0 +1,3 @@ +/** +Copyright +*/ \ No newline at end of file diff --git a/.idea/fileTemplates/internal/AnnotationType.java b/.idea/fileTemplates/internal/AnnotationType.java new file mode 100644 index 0000000..e019454 --- /dev/null +++ b/.idea/fileTemplates/internal/AnnotationType.java @@ -0,0 +1,5 @@ +#parse("Copyright.java") +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public @interface ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Class.java b/.idea/fileTemplates/internal/Class.java new file mode 100644 index 0000000..d3b689b --- /dev/null +++ b/.idea/fileTemplates/internal/Class.java @@ -0,0 +1,5 @@ +#parse("Copyright.java") +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public class ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Enum.java b/.idea/fileTemplates/internal/Enum.java new file mode 100644 index 0000000..7b6515b --- /dev/null +++ b/.idea/fileTemplates/internal/Enum.java @@ -0,0 +1,5 @@ +#parse("Copyright.java") +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public enum ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Interface.java b/.idea/fileTemplates/internal/Interface.java new file mode 100644 index 0000000..837083c --- /dev/null +++ b/.idea/fileTemplates/internal/Interface.java @@ -0,0 +1,5 @@ +#parse("Copyright.java") +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public interface ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Record.java b/.idea/fileTemplates/internal/Record.java new file mode 100644 index 0000000..8a3bd8c --- /dev/null +++ b/.idea/fileTemplates/internal/Record.java @@ -0,0 +1,5 @@ +#parse("Copyright.java") +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public record ${NAME}() { +} diff --git a/.run/Build keycloak 2fa email.run.xml b/.run/Build keycloak 2fa email.run.xml new file mode 100644 index 0000000..48fa116 --- /dev/null +++ b/.run/Build keycloak 2fa email.run.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/.run/Deploy local keycloak.run.xml b/.run/Deploy local keycloak.run.xml new file mode 100644 index 0000000..8575f65 --- /dev/null +++ b/.run/Deploy local keycloak.run.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed399d2 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Keycloak Email 2FA SPI + +[![Maven CI](https://github.com/mt-ag/keycloak-2fa-email/actions/workflows/maven.yml/badge.svg)](https://github.com/mt-ag/keycloak-2fa-email/actions/workflows/maven.yml) + +Keycloak SPI that adds an individual authenticator for two-factor authentication via email. + +## Getting started + +Build the project locally: + +```shell +git clone https://github.com/mt-ag/keycloak-2fa-email +cd keycloak-2fa-email +mvn package +``` + +Copy the generated `.jar` file from the `target/` directory, into the `keycloak/providers/` directory. + +## Setup + +### SMTP Server + +Connect Keycloak to an SMTP server in your realm's email settings. +See the [official Keycloak documentation](https://www.keycloak.org/docs/latest/server_admin/index.html#_email) for more +details on how to do so. + +### Authentication Flows + +The SPI adds a new authentication provider that can be used in browser-based Auth-flows. +First make a copy of the built-in browser flow. +Add the step `Email Verification Code` to the flow and set it to be conditional. +See: + +Auth flow example + +There are three settings for the `Email Verification Code` step: + +| Name | Description | Default | +|--------------|-------------------------------------------------|--------------------| +| Code length | Length of the generated code | `6` | +| Code Base | Used characters in the generated code | `1234567890ABCDEF` | +| Time-to-live | Time to live of the code to be valid in seconds | `300` | + +### User requirements + +A user hat to meet the following requirements to use the email 2FA provider: + +- User needs an email address in their profile +- The email address must be verified + +The `Email Verification Code` can be added to a conditional flow, so that is only used for specific users. + +## Contributing + +We are happy to receive pull request and issues. + +### Development + +First clone the repository and build the project: + +```shell +git clone https://github.com/mt-ag/keycloak-2fa-email +cd keycloak-2fa-email +mvn package +``` + +To test the SPI, you can use the `docker-compose.yml` file in the root directory of the repository. +It starts a Keycloak instance with the SPI and a MailHog instance to capture all emails sent by Keycloak. + +```shell +docker-compose up +``` + +After the first start you have to configure Keycloak to use `localhost:1025` as host and port for the SMTP server. +Then navigate your browser to `http://localhost:8025` to see all emails that have been sent by Keycloak. +To access the Keycloak admin console, use `http://localhost:8080` and log in with the credentials `admin` and `admin`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fd9cd2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' + +services: + mailhog: + image: mailhog/mailhog:latest + ports: + - "1025:1025" + - "8025:8025" + networks: + keycloak: + aliases: + - mailhog + + keycloak: + image: quay.io/keycloak/keycloak:23.0.0 + ports: + - "8080:8080" + command: [ 'start-dev' ] + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + volumes: + - type: bind + source: ./target/keycloak-2fa-email.jar + target: /opt/keycloak/providers/keycloak-2fa-email.jar + networks: + keycloak: + aliases: + - keycloak + +networks: + keycloak: diff --git a/docs/auth-flow.png b/docs/auth-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..e88edec03a40157ce860dabe4a006d3a268ebb8c GIT binary patch literal 33608 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 literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d1ecc2e --- /dev/null +++ b/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.it-solutions + keycloak-2fa-email + 1.0-SNAPSHOT + + + 17 + 17 + + 23.0.0 + UTF-8 + + + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.keycloak + keycloak-services + provided + + + + org.projectlombok + lombok + 1.18.30 + provided + + + + + + + org.keycloak + keycloak-parent + ${keycloak.version} + pom + import + + + + + + keycloak-2fa-email + + + diff --git a/src/main/java/com/mt_itsolutions/keycloak/email2fa/Auth.java b/src/main/java/com/mt_itsolutions/keycloak/email2fa/Auth.java new file mode 100644 index 0000000..ef4edc8 --- /dev/null +++ b/src/main/java/com/mt_itsolutions/keycloak/email2fa/Auth.java @@ -0,0 +1,134 @@ +/** + * Copyright + */ + +package com.mt_itsolutions.keycloak.email2fa; + +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.email.EmailException; +import org.keycloak.email.EmailTemplateProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +@Slf4j +public class Auth implements Authenticator { + + @Override + public void authenticate(AuthenticationFlowContext authenticationFlowContext) { + log.debug("EmailAuth.authenticate"); + + try { + var codeModel = generateCode(authenticationFlowContext); + sendVerificationCode(authenticationFlowContext, codeModel); + AuthChallenges.codeVerification(authenticationFlowContext); + } catch (EmailException e) { + log.error("Could not send email with verification code", e); + AuthChallenges.emailError(authenticationFlowContext); + } catch (IllegalStateException | NullPointerException e) { + log.error("Could not generate code", e); + AuthChallenges.internalError(authenticationFlowContext); + } + } + + @Override + public void action(AuthenticationFlowContext context) { + log.debug("EmailAuth.action"); + + final var enteredCode = context.getHttpRequest().getDecodedFormParameters().getFirst("code"); + final var codeModel = AuthCodeModel.readFromAuthSession(context.getAuthenticationSession()); + + // Checks if the code model is valid + if (!codeModel.isValid()) { + AuthChallenges.internalError(context); + return; + } + + // Checks if the entered code is valid + if (!codeModel.validateCode(enteredCode)) { + AuthenticationExecutionModel execution = context.getExecution(); + if (execution.isRequired()) { + AuthChallenges.codeMismatch(context); + } else if (execution.isConditional() || execution.isAlternative()) { + context.attempted(); + } + return; + } + + // Checks if the code model is expired and sends a new code + if (codeModel.isExpired()) { + try { + var newModel = generateCode(context); + sendVerificationCode(context, newModel); + AuthChallenges.codeExpired(context); + } catch (EmailException e) { + log.error("Could not send email with verification code", e); + AuthChallenges.emailError(context); + } catch (IllegalStateException | NullPointerException e) { + log.error("Could not generate code", e); + AuthChallenges.internalError(context); + } + return; + } + + context.success(); + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + final boolean hasEmail = userModel.getEmail() != null; + final boolean isEmailVerified = userModel.isEmailVerified(); + + return hasEmail && isEmailVerified; + } + + @Override + public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + // No required actions + } + + @Override + public void close() { + // No resources to close + } + + private void sendVerificationCode(AuthenticationFlowContext context, AuthCodeModel codeModel) + throws EmailException { + Map bodyAttributes = new HashMap<>(); + + bodyAttributes.put("code", codeModel.code()); + bodyAttributes.put("ttl", String.valueOf(codeModel.ttl())); + + context + .getSession() + .getProvider(EmailTemplateProvider.class) + .setAuthenticationSession(context.getAuthenticationSession()) + .setRealm(context.getRealm()) + .setUser(context.getUser()) + .send("email.2fa.mail.subject", "email.ftl", bodyAttributes); + } + + private AuthCodeModel generateCode(AuthenticationFlowContext context) throws IllegalStateException { + log.debug("EmailAuth.generateCode"); + + final var codeConfig = AuthCodeConfig.readFromConfig(context.getAuthenticatorConfig()); + final var codeModel = AuthCodeModel.createNewCode(codeConfig); + + if (codeModel.isEmpty()) { + throw new IllegalStateException("Could not create new code model"); + } + + codeModel.get().writeToAuthSession(context.getAuthenticationSession()); + return codeModel.get(); + } +} diff --git a/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthChallenges.java b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthChallenges.java new file mode 100644 index 0000000..67ac0b1 --- /dev/null +++ b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthChallenges.java @@ -0,0 +1,62 @@ +/** + * Copyright + */ + +package com.mt_itsolutions.keycloak.email2fa; + +import jakarta.ws.rs.core.Response; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AuthChallenges { + + private static final String LOGIN_PAGE = "email-login.ftl"; + + public static void codeVerification(AuthenticationFlowContext context) { + context.challenge( + context.form() + .setAttribute("realm", context.getRealm()) + .setAttribute("email", context.getUser().getEmail()) + .createForm(LOGIN_PAGE) + ); + } + + public static void emailError(AuthenticationFlowContext context) { + context.failureChallenge( + AuthenticationFlowError.INTERNAL_ERROR, + context.form() + .setError("email.2fa.error.mail.not.sent") + .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR) + ); + } + + public static void internalError(AuthenticationFlowContext context) { + context.failureChallenge( + AuthenticationFlowError.INTERNAL_ERROR, + context.form() + .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR) + ); + } + + public static void codeExpired(AuthenticationFlowContext context) { + context.failureChallenge( + AuthenticationFlowError.INVALID_CREDENTIALS, + context.form() + .setError("email.2fa.error.code.expired") + .createErrorPage(Response.Status.UNAUTHORIZED) + ); + } + + public static void codeMismatch(AuthenticationFlowContext context) { + context.failureChallenge( + AuthenticationFlowError.INVALID_CREDENTIALS, + context.form() + .setError("email.2fa.error.code.mismatch") + .createErrorPage(Response.Status.UNAUTHORIZED) + ); + } + +} diff --git a/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeConfig.java b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeConfig.java new file mode 100644 index 0000000..845ff94 --- /dev/null +++ b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeConfig.java @@ -0,0 +1,28 @@ +/** + * Copyright + */ + +package com.mt_itsolutions.keycloak.email2fa; + +import org.keycloak.models.AuthenticatorConfigModel; + +public record AuthCodeConfig(int length, int ttl, String base) { + + /** + * Read the code configuration from a config model provided by Keycloak. + * + * @param configModel a config model by Keycloak, possibly retrieved from an {@link org.keycloak.authentication.AuthenticationFlowContext}. + * @return an instance of this model containing the configuration properties + */ + public static AuthCodeConfig readFromConfig(AuthenticatorConfigModel configModel) { + var config = configModel.getConfig(); + + int length = + Integer.parseInt(config.getOrDefault(AuthFactory.AUTH_CODE_LENGTH, AuthFactory.AUTH_CODE_LENGTH_DEFAULT)); + String base = config.getOrDefault(AuthFactory.AUTH_CODE_CHARACTERS, AuthFactory.AUTH_CODE_CHARACTERS_DEFAULT); + int ttl = Integer.parseInt(config.getOrDefault(AuthFactory.AUTH_CODE_TTL, AuthFactory.AUTH_CODE_TTL_DEFAULT)); + + return new AuthCodeConfig(length, ttl, base); + } + +} diff --git a/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeModel.java b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeModel.java new file mode 100644 index 0000000..9ff82dd --- /dev/null +++ b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthCodeModel.java @@ -0,0 +1,116 @@ +/** + * Copyright + */ + +package com.mt_itsolutions.keycloak.email2fa; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Objects; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.sessions.AuthenticationSessionModel; + +/** + * @param code Random generated 2fa code + * @param ttl The number of seconds that a code should be valid + * @param creationTime Timestamp when the code was created in milliseconds + */ +@Slf4j +public record AuthCodeModel(String code, int ttl, long creationTime) { + + private static final String AUTH_CODE_NOTE = "code-value"; + private static final String AUTH_CODE_TTL_NOTE = "code-ttl"; + private static final String AUTH_CODE_CREATION_TIME_NOTE = "code-creation-time"; + + /** + * Calculates the expiration timestamp + * + * @return Timestamp when the code expires in milliseconds + */ + public long getExpirationTime() { + return creationTime + (ttl * 1000L); + } + + /** + * Validates the code, ttl and creation time + * + * @return true is when CodeModel values are initialized and valid + */ + public boolean isValid() { + return Objects.nonNull(code) && !code.isEmpty() && ttl > 0 && creationTime > 0; + } + + /** + * Validates the code + * + * @param code The code to be validated + * @return true if the code is valid + */ + public boolean validateCode(final String code) { + return this.code.equals(code); + } + + /** + * Checks if the code is expired + * + * @return true if the code is expired + */ + public boolean isExpired() { + return System.currentTimeMillis() > getExpirationTime(); + } + + /** + * Writes the code, ttl and creation time to the authentication session + * + * @param authSession The {@link org.keycloak.sessions.AuthenticationSessionModel} to which the code should be written + */ + public void writeToAuthSession(AuthenticationSessionModel authSession) { + authSession.setAuthNote(AUTH_CODE_NOTE, code); + authSession.setAuthNote(AUTH_CODE_TTL_NOTE, Integer.toString(ttl)); + authSession.setAuthNote(AUTH_CODE_CREATION_TIME_NOTE, Long.toString(creationTime)); + } + + /** + * Reads the code, ttl and creation time from the authentication session + * + * @param authSession The {@link org.keycloak.sessions.AuthenticationSessionModel} from which the code should be read + * @return An instance of CodeModel containing the code, ttl and creation time + */ + public static AuthCodeModel readFromAuthSession(final AuthenticationSessionModel authSession) { + String code = authSession.getAuthNote(AUTH_CODE_NOTE); + int ttl = Integer.parseInt(authSession.getAuthNote(AUTH_CODE_TTL_NOTE)); + long creationTime = Long.parseLong(authSession.getAuthNote(AUTH_CODE_CREATION_TIME_NOTE)); + return new AuthCodeModel(code, ttl, creationTime); + } + + /** + * Creates a new authentication code using the configuration provided + * + * @param config A {@link AuthCodeConfig} with a code base, a length and a ttl + * @return A code model with a secure random code + */ + public static Optional createNewCode(final AuthCodeConfig config) { + StringBuilder codeBuilder = new StringBuilder(); + try { + var randomInstance = SecureRandom.getInstanceStrong(); + for (int i = 0; i < config.length(); i++) { + codeBuilder.append(config.base().charAt(randomInstance.nextInt(config.base().length()))); + } + } catch (NoSuchAlgorithmException ex) { + log.error("Could not create a secure random instance", ex); + } + + String code = codeBuilder.toString(); + + if (code.length() < config.length()) { + log.error("Could not create a code with the required length"); + return Optional.empty(); + } + + long creationTime = System.currentTimeMillis(); + + return Optional.of(new AuthCodeModel(code, config.ttl(), creationTime)); + } + +} diff --git a/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthFactory.java b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthFactory.java new file mode 100644 index 0000000..3e3e549 --- /dev/null +++ b/src/main/java/com/mt_itsolutions/keycloak/email2fa/AuthFactory.java @@ -0,0 +1,123 @@ +/** + * Copyright + */ + +package com.mt_itsolutions.keycloak.email2fa; + +import java.util.List; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +public class AuthFactory implements AuthenticatorFactory { + + public static final String AUTH_CODE_LENGTH = "code-length"; + public static final String AUTH_CODE_CHARACTERS = "code-characters"; + public static final String AUTH_CODE_TTL = "code-ttl"; + public static final String AUTH_CODE_LENGTH_DEFAULT = "6"; + public static final String AUTH_CODE_CHARACTERS_DEFAULT = "1234567890ABCDEF"; + public static final String AUTH_CODE_TTL_DEFAULT = "300"; + public static final String AUTH_PROVIDER_ID = "email-2fa"; + + + private static final Auth AUTH_INSTANCE = new Auth(); + + + @Override + public String getDisplayType() { + return "Email Verification Code"; + } + + @Override + public String getReferenceCategory() { + return "info"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.CONDITIONAL, + AuthenticationExecutionModel.Requirement.DISABLED, + }; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public String getHelpText() { + return "During the Email Verification Code authentication step, the user is asked to enter a code that sent to their email address"; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + + // Auth code length + .property() + .name(AUTH_CODE_LENGTH) + .helpText("The number of digits that a code should contain") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(AUTH_CODE_LENGTH_DEFAULT) + .add() + + // Code characters + .property() + .name(AUTH_CODE_CHARACTERS) + .label("Code Base") + .helpText("The characters that will be used to generate the code") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(AUTH_CODE_CHARACTERS_DEFAULT) + .add() + + // Code time-to-live + .property() + .name(AUTH_CODE_TTL) + .label("Code Time-to-live") + .helpText("The time to live in seconds for the code to be valid.") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(AUTH_CODE_TTL_DEFAULT) + .add() + + .build(); + } + + @Override + public Authenticator create(KeycloakSession keycloakSession) { + return AUTH_INSTANCE; + } + + @Override + public void init(Config.Scope scope) { + // Nothing to do here + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + // Nothing to do here + } + + @Override + public void close() { + // Nothing to do here + } + + @Override + public String getId() { + return AUTH_PROVIDER_ID; + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..c27278d --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +com.mt_itsolutions.keycloak.email2fa.AuthFactory \ No newline at end of file diff --git a/src/main/resources/theme-resources/messages/messages_de.properties b/src/main/resources/theme-resources/messages/messages_de.properties new file mode 100644 index 0000000..c0d546f --- /dev/null +++ b/src/main/resources/theme-resources/messages/messages_de.properties @@ -0,0 +1,12 @@ +email.2fa.code=Code +email.2fa.title=Zwei Faktor Authentifizierung +email.2fa.submit=Weiter +email.2fa.info=Wir haben Ihnen eine E-Mail mit dem Verifizierungs-Code an {0} geschickt. Bitte tragen Sie diesen Code hier ein. +email.2fa.mail.salutation=Hallo, +email.2fa.mail.prompt=um den Login abzuschlie\u00DFen, geben Sie bitte den folgenden Verifizierungs-Code ein: +email.2fa.mail.ttl=Dieser Code ist für {0} Minuten g\u00FCltig. +email.2fa.mail.safety.info=Falls Sie nicht versucht haben, sich einzuloggen, oder nicht zur Eingabe eines Verifizierungs-Codes aufgefordert wurden, wenden Sie sich bitte sofort an den Support. +email.2fa.mail.subject=Ihr Verifizierungs-Code f\u00FCr die Anmeldung +email.2fa.error.code.mismatch=Der Verifizierungs-Code stimmt nicht überein +email.2fa.error.code.expired=Der Verifizierungs-Code ist bereits abgelaufen +email.2fa.error.mail.not.sent=Die E-Mail konnte leider nicht verschickt werden diff --git a/src/main/resources/theme-resources/messages/messages_en.properties b/src/main/resources/theme-resources/messages/messages_en.properties new file mode 100644 index 0000000..1f2d0ad --- /dev/null +++ b/src/main/resources/theme-resources/messages/messages_en.properties @@ -0,0 +1,12 @@ +email.2fa.code=Code +email.2fa.title=Two-factor authentification +email.2fa.submit=Next +email.2fa.info=We emailed you to {0} containing a verification code. Please retrieve the code and enter it in the form above. +email.2fa.mail.salutation=Hello, +email.2fa.mail.prompt=To complete the login process, please enter the following code: +email.2fa.mail.ttl=This code is valid for {0} minutes. +email.2fa.mail.safety.info=If you did not try to log in, or were not prompted to enter a verification code, please call your IT support. +email.2fa.mail.subject=Your verification code for logging in +email.2fa.error.code.mismatch=The verification code mismatched +email.2fa.error.code.expired=The verification code is already expired +email.2fa.error.mail.not.sent=The email could not be sent diff --git a/src/main/resources/theme-resources/templates/email-login.ftl b/src/main/resources/theme-resources/templates/email-login.ftl new file mode 100644 index 0000000..9e8ec35 --- /dev/null +++ b/src/main/resources/theme-resources/templates/email-login.ftl @@ -0,0 +1,23 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("email.2fa.title")} + <#elseif section = "form"> +
+
+
+ +
+
+ +
+
+
+
+ +
+ <#elseif section = "info"> +

${msg("email.2fa.info", email)}

+ + diff --git a/src/main/resources/theme-resources/templates/html/email.ftl b/src/main/resources/theme-resources/templates/html/email.ftl new file mode 100644 index 0000000..a0c7ca6 --- /dev/null +++ b/src/main/resources/theme-resources/templates/html/email.ftl @@ -0,0 +1,11 @@ +
+

${msg("email.2fa.mail.salutation")}

+ +

${msg("email.2fa.mail.prompt")}

+ +

${code}

+ +

${msg("email.2fa.mail.ttl", ttl)}

+ +

${msg("email.2fa.mail.safety.info")}

+
diff --git a/src/main/resources/theme-resources/templates/text/email.ftl b/src/main/resources/theme-resources/templates/text/email.ftl new file mode 100644 index 0000000..c58606b --- /dev/null +++ b/src/main/resources/theme-resources/templates/text/email.ftl @@ -0,0 +1,9 @@ +${msg("email.2fa.mail.salutation")} + +${msg("email.2fa.mail.prompt")} + +${code} + +${msg("email.2fa.mail.ttl", ttl)} + +${msg("email.2fa.mail.safety.info")} diff --git a/src/main/resources/theme-resources/theme.properties b/src/main/resources/theme-resources/theme.properties new file mode 100644 index 0000000..5265964 --- /dev/null +++ b/src/main/resources/theme-resources/theme.properties @@ -0,0 +1,2 @@ +parent=keycloak +import=common/keycloak