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 0000000..e88edec Binary files /dev/null and b/docs/auth-flow.png differ 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