Skip to content

Commit

Permalink
Custom provider for sending otp via email
Browse files Browse the repository at this point in the history
  • Loading branch information
mesutpiskin committed Oct 20, 2022
1 parent 54dfd64 commit 1ba0fd6
Show file tree
Hide file tree
Showing 14 changed files with 486 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
.DS_Store
target/*
.idea
38 changes: 36 additions & 2 deletions README.md
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">
82 changes: 82 additions & 0 deletions dynamic-email-code-auth-extension.iml
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>
87 changes: 87 additions & 0 deletions pom.xml
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>
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());
}
}
}
Loading

0 comments on commit 1ba0fd6

Please sign in to comment.