-
Notifications
You must be signed in to change notification settings - Fork 94
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Custom provider for sending otp via email
- Loading branch information
1 parent
54dfd64
commit 1ba0fd6
Showing
14 changed files
with
486 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,36 @@ | ||
# keycloak-2fa-email-authenticator- | ||
Keycloak Authentication Provider implementation to get a 2nd-factor authentication with a OTP/code/token send via Email (through SMTP) | ||
# Keycloak 2FA Email Authenticator | ||
|
||
Keycloak Authentication Provider implementation to get a 2nd-factor authentication with an OTP/code/token send via Email (through SMTP) | ||
|
||
When logging in with this provider, you can send a verification code (otp) to the user's e-mail address. | ||
Tested with Keycloak version 19.0.3, if you are using different Keycloak version, don't forget to change the version in pom.xml file. | ||
|
||
The [Server Development part of the Keycloak reference documentation](https://www.keycloak.org/docs/latest/server_development/index.html) contains additional resources and examples for developing custom Keycloak extensions. | ||
|
||
# Deployment | ||
|
||
## Provider | ||
|
||
`mvn package` will be create a jar file. | ||
copy _keycloak-2fa-email-authenticator-1.0.0.0-SNAPSHOT.jar_ to _keycloak/providers/_ directory. | ||
|
||
if you are Dockerized keycloak then copy to _/opt/jboss/keycloak/standalone/deployments/_ directory. | ||
|
||
## Theme Resources | ||
|
||
**html/code-email.ftl** is a html email template. Copy to _themes/base/email/html/_ | ||
|
||
**text/code-email.ftl** Copy to _themes/base/email/text/_ | ||
|
||
**messages/*.properties** Append to _themes/base/email/messages/messages_en.properties_ | ||
|
||
# Configuration | ||
|
||
## Email Configuration | ||
SMTP setting configure for e-mail send. | ||
_Realm Settings/Email_ | ||
|
||
## Authentication Flow | ||
Create new browser login authentication flow and add Email OTP flow before Username Password Form. | ||
|
||
<img src="static/otp-form.png"> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<module org.jetbrains.idea.maven.project.MavenProjectsManager.isMavenModule="true" type="JAVA_MODULE" version="4"> | ||
<component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_11"> | ||
<output url="file://$MODULE_DIR$/target/classes" /> | ||
<output-test url="file://$MODULE_DIR$/target/test-classes" /> | ||
<content url="file://$MODULE_DIR$"> | ||
<sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" /> | ||
<sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" /> | ||
<excludeFolder url="file://$MODULE_DIR$/target" /> | ||
</content> | ||
<orderEntry type="inheritedJdk" /> | ||
<orderEntry type="sourceFolder" forTests="false" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.projectlombok:lombok:1.18.22" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.auto.service:auto-service:1.0.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.auto.service:auto-service-annotations:1.0.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.auto:auto-common:1.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.guava:guava:31.0.1-jre" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.guava:failureaccess:1.0.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.code.findbugs:jsr305:3.0.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.checkerframework:checker-qual:3.12.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.errorprone:error_prone_annotations:2.7.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.j2objc:j2objc-annotations:1.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.keycloak:keycloak-server-spi:19.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.keycloak:keycloak-server-spi-private:19.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.github.ua-parser:uap-java:1.5.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.yaml:snakeyaml:1.26" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.apache.commons:commons-collections4:4.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.keycloak:keycloak-services:19.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.bouncycastle:bcprov-jdk15on:1.68" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.bouncycastle:bcpkix-jdk15on:1.68" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.keycloak:keycloak-core:19.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.sun.mail:jakarta.mail:1.6.5" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.sun.activation:jakarta.activation:1.2.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.glassfish:jakarta.json:1.1.6" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.keycloak:keycloak-common:19.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.twitter4j:twitter4j-core:4.0.7" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.logging:jboss-logging:3.4.1.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.spec.javax.ws.rs:jboss-jaxrs-api_2.1_spec:2.0.1.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.spec.javax.transaction:jboss-transaction-api_1.3_spec:2.0.0.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.resteasy:resteasy-multipart-provider:4.7.4.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.resteasy:resteasy-core-spi:4.7.4.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.spec.javax.annotation:jboss-annotations-api_1.3_spec:2.0.1.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.spec.javax.xml.bind:jboss-jaxb-api_2.3_spec:2.0.0.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.reactivestreams:reactive-streams:1.0.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: jakarta.validation:jakarta.validation-api:2.0.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.resteasy:resteasy-core:4.7.5.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: jakarta.activation:jakarta.activation-api:1.2.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.ibm.async:asyncutil:0.1.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.config:smallrye-config:2.3.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.config:smallrye-config-core:2.3.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.eclipse.microprofile.config:microprofile-config-api:2.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.common:smallrye-common-annotation:1.6.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.common:smallrye-common-expression:1.6.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.common:smallrye-common-function:1.6.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.common:smallrye-common-constraint:1.6.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.common:smallrye-common-classloader:1.6.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.ow2.asm:asm:9.1" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: io.smallrye.config:smallrye-config-common:2.3.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.jboss.resteasy:resteasy-jaxb-provider:4.7.4.Final" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.glassfish.jaxb:jaxb-runtime:2.3.3-b02" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.glassfish.jaxb:txw2:2.3.3-b02" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.sun.istack:istack-commons-runtime:3.0.10" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.apache.james:apache-mime4j-dom:0.8.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.apache.james:apache-mime4j-core:0.8.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.apache.james:apache-mime4j-storage:0.8.3" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: commons-io:commons-io:2.4" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20211018.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.fasterxml.jackson.core:jackson-core:2.13.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.fasterxml.jackson.core:jackson-databind:2.13.2.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.fasterxml.jackson.core:jackson-annotations:2.13.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.zxing:javase:3.4.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.google.zxing:core:3.4.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.github.jai-imageio:jai-imageio-core:1.4.0" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.webauthn4j:webauthn4j-core:0.20.0.RELEASE" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.webauthn4j:webauthn4j-util:0.20.0.RELEASE" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.slf4j:slf4j-api:1.7.36" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.apache.kerby:kerby-asn1:2.0.2" level="project" /> | ||
<orderEntry type="library" scope="PROVIDED" name="Maven: com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.13.3" level="project" /> | ||
</component> | ||
</module> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<project xmlns="http://maven.apache.org/POM/4.0.0" | ||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
|
||
<artifactId>keycloak-2fa-email-authenticator</artifactId> | ||
<groupId>com.mesutpiskin.keycloak</groupId> | ||
<version>1.0.0.0-SNAPSHOT</version> | ||
<modelVersion>4.0.0</modelVersion> | ||
<packaging>jar</packaging> | ||
|
||
<properties> | ||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||
<java.version>11</java.version> | ||
<maven.compiler.source>${java.version}</maven.compiler.source> | ||
<maven.compiler.target>${java.version}</maven.compiler.target> | ||
<lombok.version>1.18.22</lombok.version> | ||
<keycloak.version>19.0.3</keycloak.version> | ||
<auto-service.version>1.0.1</auto-service.version> | ||
</properties> | ||
|
||
<dependencies> | ||
<dependency> | ||
<groupId>org.projectlombok</groupId> | ||
<artifactId>lombok</artifactId> | ||
<optional>true</optional> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>org.keycloak</groupId> | ||
<artifactId>keycloak-server-spi</artifactId> | ||
<version>${keycloak.version}</version> | ||
<scope>provided</scope> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>org.keycloak</groupId> | ||
<artifactId>keycloak-server-spi-private</artifactId> | ||
<version>${keycloak.version}</version> | ||
<scope>provided</scope> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>org.keycloak</groupId> | ||
<artifactId>keycloak-services</artifactId> | ||
<version>${keycloak.version}</version> | ||
<scope>provided</scope> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>org.projectlombok</groupId> | ||
<artifactId>lombok</artifactId> | ||
<version>${lombok.version}</version> | ||
<scope>provided</scope> | ||
<optional>true</optional> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>com.google.auto.service</groupId> | ||
<artifactId>auto-service</artifactId> | ||
<version>${auto-service.version}</version> | ||
<scope>provided</scope> | ||
<optional>true</optional> | ||
</dependency> | ||
|
||
</dependencies> | ||
|
||
<build> | ||
<plugins> | ||
<plugin> | ||
<groupId>org.apache.maven.plugins</groupId> | ||
<artifactId>maven-jar-plugin</artifactId> | ||
<configuration> | ||
<archive> | ||
<!-- This is required since we need to add the jboss module references | ||
to the resulting jar --> | ||
<manifestEntries> | ||
<!-- Adding explicit dependencies to avoid class-loading issues at runtime --> | ||
<Dependencies> | ||
<![CDATA[org.keycloak.keycloak-common,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.apache.httpcomponents,org.keycloak.keycloak-services,org.jboss.logging,javax.api,javax.jms.api,javax.transaction.api,com.fasterxml.jackson.core.jackson-core,com.fasterxml.jackson.core.jackson-annotations,com.fasterxml.jackson.core.jackson-databind]]></Dependencies> | ||
</manifestEntries> | ||
</archive> | ||
</configuration> | ||
</plugin> | ||
</plugins> | ||
</build> | ||
</project> |
146 changes: 146 additions & 0 deletions
146
src/main/java/com/mesutpiskin/keycloak/auth/email/EmailAuthenticatorForm.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
package com.mesutpiskin.keycloak.auth.email; | ||
|
||
import lombok.extern.jbosslog.JBossLog; | ||
import org.keycloak.authentication.AuthenticationFlowContext; | ||
import org.keycloak.authentication.AuthenticationFlowError; | ||
import org.keycloak.authentication.AuthenticationFlowException; | ||
import org.keycloak.authentication.Authenticator; | ||
import org.keycloak.email.EmailException; | ||
import org.keycloak.email.EmailTemplateProvider; | ||
import org.keycloak.events.Errors; | ||
import org.keycloak.forms.login.LoginFormsProvider; | ||
import org.keycloak.models.KeycloakSession; | ||
import org.keycloak.models.RealmModel; | ||
import org.keycloak.models.UserModel; | ||
import org.keycloak.models.utils.FormMessage; | ||
import org.keycloak.services.messages.Messages; | ||
|
||
import javax.ws.rs.core.MultivaluedMap; | ||
import javax.ws.rs.core.Response; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.concurrent.ThreadLocalRandom; | ||
|
||
@JBossLog | ||
public class EmailAuthenticatorForm implements Authenticator { | ||
|
||
static final String ID = "demo-email-code-form"; | ||
|
||
public static final String EMAIL_CODE = "emailCode"; | ||
|
||
private final KeycloakSession session; | ||
|
||
public EmailAuthenticatorForm(KeycloakSession session) { | ||
this.session = session; | ||
} | ||
|
||
@Override | ||
public void authenticate(AuthenticationFlowContext context) { | ||
challenge(context, null); | ||
} | ||
|
||
private void challenge(AuthenticationFlowContext context, FormMessage errorMessage) { | ||
|
||
generateAndSendEmailCode(context); | ||
|
||
LoginFormsProvider form = context.form().setExecution(context.getExecution().getId()); | ||
if (errorMessage != null) { | ||
form.setErrors(List.of(errorMessage)); | ||
} | ||
|
||
Response response = form.createForm("email-code-form.ftl"); | ||
context.challenge(response); | ||
} | ||
|
||
private void generateAndSendEmailCode(AuthenticationFlowContext context) { | ||
|
||
if (context.getAuthenticationSession().getAuthNote(EMAIL_CODE) != null) { | ||
// skip sending email code | ||
return; | ||
} | ||
|
||
int emailCode = ThreadLocalRandom.current().nextInt(99999999); | ||
sendEmailWithCode(context.getRealm(), context.getUser(), String.valueOf(emailCode)); | ||
context.getAuthenticationSession().setAuthNote(EMAIL_CODE, Integer.toString(emailCode)); | ||
} | ||
|
||
@Override | ||
public void action(AuthenticationFlowContext context) { | ||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters(); | ||
if (formData.containsKey("resend")) { | ||
resetEmailCode(context); | ||
challenge(context, null); | ||
return; | ||
} | ||
|
||
if (formData.containsKey("cancel")) { | ||
resetEmailCode(context); | ||
context.resetFlow(); | ||
return; | ||
} | ||
|
||
int givenEmailCode = Integer.parseInt(formData.getFirst(EMAIL_CODE)); | ||
boolean valid = validateCode(context, givenEmailCode); | ||
if (!valid) { | ||
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); | ||
challenge(context, new FormMessage(Messages.INVALID_ACCESS_CODE)); | ||
return; | ||
} | ||
|
||
resetEmailCode(context); | ||
context.success(); | ||
} | ||
|
||
private void resetEmailCode(AuthenticationFlowContext context) { | ||
context.getAuthenticationSession().removeAuthNote(EMAIL_CODE); | ||
} | ||
|
||
private boolean validateCode(AuthenticationFlowContext context, int givenCode) { | ||
int emailCode = Integer.parseInt(context.getAuthenticationSession().getAuthNote(EMAIL_CODE)); | ||
return givenCode == emailCode; | ||
} | ||
|
||
@Override | ||
public boolean requiresUser() { | ||
return true; | ||
} | ||
|
||
@Override | ||
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { | ||
return true; | ||
} | ||
|
||
@Override | ||
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { | ||
// NOOP | ||
} | ||
|
||
@Override | ||
public void close() { | ||
// NOOP | ||
} | ||
|
||
private void sendEmailWithCode(RealmModel realm, UserModel user, String code) { | ||
if (user.getEmail() == null) { | ||
log.warnf("Could not send access code email due to missing email. realm=%s user=%s", realm.getId(), user.getUsername()); | ||
throw new AuthenticationFlowException(AuthenticationFlowError.INVALID_USER); | ||
} | ||
|
||
Map<String, Object> mailBodyAttributes = new HashMap<>(); | ||
mailBodyAttributes.put("username", user.getUsername()); | ||
mailBodyAttributes.put("code", code); | ||
|
||
String realmName = realm.getDisplayName() != null ? realm.getDisplayName() : realm.getName(); | ||
List<Object> subjectParams = List.of(realmName); | ||
try { | ||
EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class); | ||
emailProvider.setRealm(realm); | ||
emailProvider.setUser(user); | ||
// Don't forget to add the welcome-email.ftl (html and text) template to your theme. | ||
emailProvider.send("emailCodeSubject", subjectParams, "code-email.ftl", mailBodyAttributes); | ||
} catch (EmailException eex) { | ||
log.errorf(eex, "Failed to send access code email. realm=%s user=%s", realm.getId(), user.getUsername()); | ||
} | ||
} | ||
} |
Oops, something went wrong.