diff --git a/README.md b/README.md index a12447b..b2e7cb4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +# Keycloak extension for API key authentication + +The extension contains providers for supporting API key authentication, and also other non related providers like a custom `EmailSenderProvider` (for demo purposes). + +It also contains a customization of the account console (the user info page provided by Keycloak) showing the API key. The account console is accessible at `/auth/realms/{realm_name}/account` and requires the user to be already authenticated. + +The master branch uses the new Keycloak distribution powered by Quarkus. For Legacy keycloak (versions < 17.0.0), you can switch to the `legacy` branch. ## How to run you can run the project by running the following from a terminal: `mvn -f api-key-module package && mvn -f dashboard-service package && docker-compose up` @@ -8,6 +15,6 @@ Note: You need to add `auth-server` to your hosts file (`/etc/hosts` for linux) 1. Navigate to localhost:8180 in a browser, you will redirected to keycloak for authentication 2. you need register a new user, after which you will be redirected to the main dashboard page which will show your API key -3. copy the API key and use it to call the API: `curl -v -H "x-api-key: $THE_API_KEY" localhost:8200`, if you omit the API key, you will get 401 status +3. copy the API key and use it to call the API: `curl -v -H "x-api-key: $THE_API_KEY" localhost:8280`, if you omit the API key, you will get 401 status More explanations can be found in this blog [post](http://www.zakariaamine.com/2019-06-14/extending-keycloak) diff --git a/api-key-module/pom.xml b/api-key-module/pom.xml index f2905d6..8c0c46c 100644 --- a/api-key-module/pom.xml +++ b/api-key-module/pom.xml @@ -10,7 +10,7 @@ 11 - 15.0.2 + 19.0.1 diff --git a/api-key-module/src/main/java/com/gwidgets/providers/RegisterEventListenerProvider.java b/api-key-module/src/main/java/com/gwidgets/providers/RegisterEventListenerProvider.java index 241639c..035693d 100644 --- a/api-key-module/src/main/java/com/gwidgets/providers/RegisterEventListenerProvider.java +++ b/api-key-module/src/main/java/com/gwidgets/providers/RegisterEventListenerProvider.java @@ -4,7 +4,7 @@ package com.gwidgets.providers; import java.util.Objects; import java.util.UUID; import javax.persistence.EntityManager; -import org.keycloak.common.util.RandomString; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; @@ -23,14 +23,14 @@ public class RegisterEventListenerProvider implements EventListenerProvider { private KeycloakSession session; private RealmProvider model; //keycloak utility to generate random strings, anything can be used e.g UUID,.. - private RandomString randomString; + private SecretGenerator secretGenerator; private EntityManager entityManager; public RegisterEventListenerProvider(KeycloakSession session) { this.session = session; this.model = session.realms(); this.entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - this.randomString = new RandomString(50); + this.secretGenerator = SecretGenerator.getInstance(); } public void onEvent(Event event) { @@ -55,8 +55,7 @@ public class RegisterEventListenerProvider implements EventListenerProvider { public void addApiKeyAttribute(String userId) { - - String apiKey = randomString.nextString(); + String apiKey = secretGenerator.randomString(50); UserEntity userEntity = entityManager.find(UserEntity.class, userId); UserAttributeEntity attributeEntity = new UserAttributeEntity(); attributeEntity.setName("api-key"); diff --git a/api-key-module/src/main/java/com/gwidgets/providers/SESEmailSenderProvider.java b/api-key-module/src/main/java/com/gwidgets/providers/SESEmailSenderProvider.java index 0939afc..e8c0b7e 100644 --- a/api-key-module/src/main/java/com/gwidgets/providers/SESEmailSenderProvider.java +++ b/api-key-module/src/main/java/com/gwidgets/providers/SESEmailSenderProvider.java @@ -8,6 +8,7 @@ import com.amazonaws.services.simpleemail.model.Message; import com.amazonaws.services.simpleemail.model.SendEmailRequest; import java.util.Map; import org.jboss.logging.Logger; +import org.keycloak.email.EmailException; import org.keycloak.email.EmailSenderProvider; import org.keycloak.models.UserModel; @@ -24,20 +25,25 @@ public class SESEmailSenderProvider implements EmailSenderProvider { @Override public void send(Map config, UserModel user, String subject, String textBody, - String htmlBody) { + String htmlBody) throws EmailException { + this.send(config, user.getEmail(), subject, textBody, htmlBody); + } - log.info("attempting to send email using aws ses for " + user.getEmail()); + @Override + public void send(Map config, String address, String subject, String textBody, String htmlBody) throws EmailException { - Message message = new Message().withSubject(new Content().withData(subject)) - .withBody(new Body().withHtml(new Content().withData(htmlBody)) - .withText(new Content().withData(textBody).withCharset("UTF-8"))); + log.info("attempting to send email using aws ses for " + address); - SendEmailRequest sendEmailRequest = new SendEmailRequest() - .withSource("example<" + config.get("from") + ">") - .withMessage(message).withDestination(new Destination().withToAddresses(user.getEmail())); + Message message = new Message().withSubject(new Content().withData(subject)) + .withBody(new Body().withHtml(new Content().withData(htmlBody)) + .withText(new Content().withData(textBody).withCharset("UTF-8"))); - sesClient.sendEmail(sendEmailRequest); - log.info("email sent to " + user.getEmail() + " successfully"); + SendEmailRequest sendEmailRequest = new SendEmailRequest() + .withSource("example<" + config.get("from") + ">") + .withMessage(message).withDestination(new Destination().withToAddresses(address)); + + sesClient.sendEmail(sendEmailRequest); + log.info("email sent to " + address + " successfully"); } @Override diff --git a/api-key-module/src/main/java/com/gwidgets/resources/ApiKeyResource.java b/api-key-module/src/main/java/com/gwidgets/resources/ApiKeyResource.java index 30e9155..08c7d1c 100644 --- a/api-key-module/src/main/java/com/gwidgets/resources/ApiKeyResource.java +++ b/api-key-module/src/main/java/com/gwidgets/resources/ApiKeyResource.java @@ -1,14 +1,15 @@ package com.gwidgets.resources; -import java.util.List; -import java.util.Objects; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + import javax.ws.rs.GET; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; +import java.util.Objects; +import java.util.stream.Stream; public class ApiKeyResource { @@ -25,7 +26,7 @@ public class ApiKeyResource { @GET @Produces("application/json") public Response checkApiKey(@QueryParam("apiKey") String apiKey) { - List result = session.userStorageManager().searchForUserByUserAttribute("api-key", apiKey, session.realms().getRealm(realmName)); - return result.isEmpty() ? Response.status(401).type(MediaType.APPLICATION_JSON).build(): Response.ok().type(MediaType.APPLICATION_JSON).build(); + Stream result = session.users().searchForUserByUserAttributeStream(session.realms().getRealm(realmName), "api-key", apiKey); + return result.count() > 0 ? Response.ok().type(MediaType.APPLICATION_JSON).build(): Response.status(401).type(MediaType.APPLICATION_JSON).build(); } } diff --git a/dashboard-service/pom.xml b/dashboard-service/pom.xml index 2bf6dd7..c9a347d 100644 --- a/dashboard-service/pom.xml +++ b/dashboard-service/pom.xml @@ -16,7 +16,7 @@ 11 - 15.0.2 + 19.0.1 diff --git a/docker-compose.yaml b/docker-compose.yaml index f1822e8..7e72ef0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,16 +1,22 @@ -version: '3.7' +version: '3.8' services: auth-server: - image: jboss/keycloak:15.0.2 + image: quay.io/keycloak/keycloak:19.0.1 environment: - KEYCLOAK_USER: admin - KEYCLOAK_PASSWORD: admin - JAVA_OPTS_APPEND: "-Dkeycloak.migration.action=import -Dkeycloak.migration.provider=dir -Dkeycloak.migration.dir=/import -Dkeycloak.migration.strategy=IGNORE_EXISTING -Dkeycloak.profile.feature.upload_scripts=enabled" + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HTTP_ENABLED: "true" + KC_HOSTNAME: auth-server + #to keep compatible with other services that are expecting /auth + KC_HTTP_RELATIVE_PATH: /auth + KC_HOSTNAME_STRICT_HTTPS: "false" + JAVA_OPTS_APPEND: "-Dkeycloak.migration.action=import -Dkeycloak.migration.provider=dir -Dkeycloak.migration.dir=/import -Dkeycloak.migration.strategy=IGNORE_EXISTING" volumes: - ./import:/import - - ./api-key-module/target/deploy:/opt/jboss/keycloak/standalone/deployments/ + - ./api-key-module/target/deploy:/opt/keycloak/providers/ ports: - "8080:8080" + command: ["start"] dashboard-service: build: dashboard-service environment: diff --git a/import/example-realm.json b/import/example-realm.json index 475a350..376aca0 100644 --- a/import/example-realm.json +++ b/import/example-realm.json @@ -192,31 +192,7 @@ ] } ], - "policies": [ - { - "id": "6fc4ef68-6935-4bc1-b1fe-9b7109e43fe1", - "name": "Default Policy", - "description": "A policy that grants access only for users within this realm", - "type": "js", - "logic": "POSITIVE", - "decisionStrategy": "AFFIRMATIVE", - "config": { - "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" - } - }, - { - "id": "54f768bd-be40-4b44-acb5-9218d5fd3e2b", - "name": "Default Permission", - "description": "A permission that applies to the default resource type", - "type": "resource", - "logic": "POSITIVE", - "decisionStrategy": "UNANIMOUS", - "config": { - "defaultResourceType": "urn:dashboard-client:resources:default", - "applyPolicies": "[\"Default Policy\"]" - } - } - ], + "policies": [], "scopes": [] } },