keycloak-memoizeit
Changes
adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KcinitDriver.java 707(+707 -0)
adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java 281(+0 -281)
adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java 371(+237 -134)
adapters/oidc/kcinit/pom.xml 4(+2 -2)
adapters/oidc/kcinit/README.md 0(+0 -0)
adapters/oidc/kcinit/src/main/bin/kcinit 26(+26 -0)
adapters/oidc/kcinit-dist/assembly.xml 49(+49 -0)
adapters/oidc/kcinit-dist/pom.xml 69(+69 -0)
adapters/oidc/pom.xml 3(+2 -1)
pom.xml 11(+11 -0)
services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticator.java 11(+9 -2)
services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java 10(+10 -0)
services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java 11(+10 -1)
services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleOTPFormAuthenticator.java 76(+76 -0)
services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticator.java 122(+122 -0)
services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticatorFactory.java 102(+102 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleTermsAndConditions.java 104(+104 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdatePassword.java 103(+103 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateProfile.java 94(+94 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateTotp.java 144(+144 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleVerifyEmail.java 178(+178 -0)
services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java 18(+17 -1)
services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java 4(+4 -0)
services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java 2(+2 -0)
services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory 2(+1 -1)
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java 22(+22 -0)
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcinitExec.java 58(+58 -0)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/KcinitTest.java 499(+499 -0)
Details
diff --git a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KcinitDriver.java b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KcinitDriver.java
new file mode 100644
index 0000000..790cbcd
--- /dev/null
+++ b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KcinitDriver.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.adapters.installed;
+
+import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.KeycloakDeploymentBuilder;
+import org.keycloak.adapters.ServerRequest;
+import org.keycloak.common.util.Base64;
+import org.keycloak.common.util.Time;
+import org.keycloak.jose.jwe.*;
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
+import org.keycloak.util.JsonSerialization;
+
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.Form;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.*;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.util.*;
+
+/**
+ * All kcinit commands that take input ask for
+ * <p>
+ * 1. . kcinit
+ * - setup and export KC_SESSION_KEY env var if not set.
+ * - checks to see if master token valid, refresh is possible, exit if token valid
+ * - performs command line login
+ * - stores master token for master client
+ * 2. app.sh is a wrapper for app cli.
+ * - token=`kcinit token app`
+ * - checks to see if token for app client has been fetched, refresh if valid, output token to sys.out if exists
+ * - if no token, login. Prompts go to stderr.
+ * - pass token as cmd line param to app or as environment variable.
+ * <p>
+ * 3. kcinit password {password}
+ * - outputs password key that is used for encryption.
+ * - can be used in .bashrc as export KC_SESSSION_KEY=`kcinit password {password}` or just set it in .bat file
+ * <p>
+ *
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class KcinitDriver {
+
+ public static final String KC_SESSION_KEY = "KC_SESSION_KEY";
+ public static final String KC_LOGIN_CONFIG_PATH = "KC_LOGIN_CONFIG_PATH";
+ protected Map<String, String> config;
+ protected boolean debug = true;
+
+ protected static byte[] salt = new byte[]{-4, 88, 66, -101, 78, -94, 21, 105};
+
+ String[] args = null;
+
+ protected boolean forceLogin;
+ protected boolean browserLogin;
+
+ public void mainCmd(String[] args) throws Exception {
+
+ this.args = Arrays.copyOf(args, args.length);
+ for (String arg : args) {
+ if (!arg.startsWith("-")) break;
+ if (arg.equals("-f") || arg.equals("-force")) {
+ forceLogin = true;
+ this.args = Arrays.copyOfRange(this.args, 1, this.args.length);
+ } else if (arg.equals("-browser") || arg.equals("-b")) {
+ browserLogin = true;
+ this.args = Arrays.copyOfRange(this.args, 1, this.args.length);
+ } else {
+ System.err.println("Illegal argument: " + arg);
+ printHelp();
+ System.exit(1);
+ }
+ }
+
+ this.args = args;
+
+
+ if (args.length == 0) {
+ login();
+ return;
+ }
+
+ if (args[0].startsWith("-")) {
+ login();
+ return;
+ }
+
+ if (args[0].equalsIgnoreCase("token")) {
+ //System.err.println("executing token");
+ token();
+ } else if (args[0].equalsIgnoreCase("logout")) {
+ logout();
+ } else if (args[0].equalsIgnoreCase("env")) {
+ System.out.println(System.getenv().toString());
+ } else if (args[0].equalsIgnoreCase("install")) {
+ install();
+ } else if (args[0].equalsIgnoreCase("uninstall")) {
+ uninstall();
+ } else if (args[0].equalsIgnoreCase("password")) {
+ passwordKey();
+ } else {
+ KeycloakInstalled.console().writer().println("Unknown command: " + args[0]);
+ KeycloakInstalled.console().writer().println();
+ printHelp();
+ }
+ }
+
+ public String getHome() {
+ String home = System.getenv("HOME");
+ if (home == null) {
+ home = System.getProperty("HOME");
+ if (home == null) {
+ home = Paths.get("").toAbsolutePath().normalize().toString();
+ }
+ }
+ return home;
+ }
+
+ public void passwordKey() {
+ if (args.length < 2) {
+ printHelp();
+ System.exit(1);
+ }
+ String password = args[1];
+ try {
+ String encodedKey = generateEncryptionKey(password);
+ System.out.printf(encodedKey);
+ } catch (Exception e) {
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ protected String generateEncryptionKey(String password) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 100, 128);
+ SecretKey tmp = factory.generateSecret(spec);
+ byte[] aeskey = tmp.getEncoded();
+ return Base64.encodeBytes(aeskey);
+ }
+
+ public JWE createJWE() {
+ String key = getEncryptionKey();
+ if (key == null) {
+ throw new RuntimeException(KC_SESSION_KEY + " env var not set");
+ }
+ byte[] aesKey = null;
+ try {
+ aesKey = Base64.decode(key.getBytes("UTF-8"));
+ } catch (IOException e) {
+ throw new RuntimeException("invalid " + KC_SESSION_KEY + "env var");
+ }
+
+ JWE jwe = new JWE();
+ final SecretKey aesSecret = new SecretKeySpec(aesKey, "AES");
+ jwe.getKeyStorage()
+ .setEncryptionKey(aesSecret);
+ return jwe;
+ }
+
+ protected String encryptionKey;
+
+ protected String getEncryptionKey() {
+ if (encryptionKey != null) return encryptionKey;
+ return System.getenv(KC_SESSION_KEY);
+ }
+
+ public String encrypt(String payload) {
+ JWE jwe = createJWE();
+ JWEHeader jweHeader = new JWEHeader(JWEConstants.A128KW, JWEConstants.A128CBC_HS256, null);
+ try {
+ jwe.header(jweHeader).content(payload.getBytes("UTF-8"));
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("cannot encode payload as UTF-8");
+ }
+ try {
+ return jwe.encodeJwe();
+ } catch (JWEException e) {
+ throw new RuntimeException("cannot encrypt payload", e);
+ }
+ }
+
+ public String decrypt(String encoded) {
+ JWE jwe = createJWE();
+ try {
+ jwe.verifyAndDecodeJwe(encoded);
+ byte[] content = jwe.getContent();
+ if (content == null) return null;
+ return new String(content, "UTF-8");
+ } catch (Exception ex) {
+ throw new RuntimeException("cannot decrypt payload", ex);
+
+ }
+
+ }
+
+ public static String getenv(String name, String defaultValue) {
+ String val = System.getenv(name);
+ return val == null ? defaultValue : val;
+ }
+
+ public File getConfigDirectory() {
+ return Paths.get(getHome(), getenv(KC_LOGIN_CONFIG_PATH, ".keycloak"), "kcinit").toFile();
+ }
+
+
+ public File getConfigFile() {
+ return Paths.get(getHome(), getenv(KC_LOGIN_CONFIG_PATH, ".keycloak"), "kcinit", "config.json").toFile();
+ }
+
+ public File getTokenFilePath(String client) {
+ return Paths.get(getHome(), getenv(KC_LOGIN_CONFIG_PATH, ".keycloak"), "kcinit", "tokens", client).toFile();
+ }
+
+ public File getTokenDirectory() {
+ return Paths.get(getHome(), getenv(KC_LOGIN_CONFIG_PATH, ".keycloak"), "kcinit", "tokens").toFile();
+ }
+
+ protected boolean encrypted = false;
+
+ protected void checkEnv() {
+ File configFile = getConfigFile();
+ if (!configFile.exists()) {
+ KeycloakInstalled.console().writer().println("You have not configured kcinit. Please run 'kcinit install' to configure.");
+ System.exit(1);
+ }
+ byte[] data = new byte[0];
+ try {
+ data = readFileRaw(configFile);
+ } catch (IOException e) {
+
+ }
+ if (data == null) {
+ KeycloakInstalled.console().writer().println("Config file unreadable. Please run 'kcinit install' to configure.");
+ System.exit(1);
+
+ }
+ String encodedJwe = null;
+ try {
+ encodedJwe = new String(data, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ e.printStackTrace();
+ }
+
+ if (encodedJwe.contains("realm")) {
+ encrypted = false;
+ return;
+ } else {
+ encrypted = true;
+ }
+
+ if (System.getenv(KC_SESSION_KEY) == null) {
+ promptLocalPassword();
+ }
+ }
+
+ protected void promptLocalPassword() {
+ String password = KeycloakInstalled.console().passwordPrompt("Enter password to unlock kcinit config files: ");
+ try {
+ encryptionKey = generateEncryptionKey(password);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+ protected String readFile(File fp) {
+ try {
+ byte[] data = readFileRaw(fp);
+ if (data == null) return null;
+ String file = new String(data, "UTF-8");
+ if (!encrypted) {
+ return file;
+ }
+ String decrypted = decrypt(file);
+ if (decrypted == null)
+ throw new RuntimeException("Unable to decrypt file. Did you set your local password correctly?");
+ return decrypted;
+ } catch (IOException e) {
+ throw new RuntimeException("failed to decrypt file: " + fp.getAbsolutePath() + " Did you set your local password correctly?", e);
+ }
+
+
+ }
+
+ protected byte[] readFileRaw(File fp) throws IOException {
+ if (!fp.exists()) return null;
+ FileInputStream fis = new FileInputStream(fp);
+ byte[] data = new byte[(int) fp.length()];
+ fis.read(data);
+ fis.close();
+ return data;
+ }
+
+ protected void writeFile(File fp, String payload) {
+ try {
+ String data = payload;
+ if (encrypted) data = encrypt(payload);
+ FileOutputStream fos = new FileOutputStream(fp);
+ fos.write(data.getBytes("UTF-8"));
+ fos.flush();
+ fos.close();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+ public void install() {
+ if (getEncryptionKey() == null) {
+ if (KeycloakInstalled.console().confirm("Do you want to protect tokens stored locally with a password? (y/n): ")) {
+ String password = "p";
+ String confirm = "c";
+ do {
+ password = KeycloakInstalled.console().passwordPrompt("Enter local password: ");
+ confirm = KeycloakInstalled.console().passwordPrompt("Confirm local password: ");
+ if (!password.equals(confirm)) {
+ KeycloakInstalled.console().writer().println();
+ KeycloakInstalled.console().writer().println("Confirmation does not match. Try again.");
+ KeycloakInstalled.console().writer().println();
+ }
+ } while (!password.equals(confirm));
+ try {
+ this.encrypted = true;
+ this.encryptionKey = generateEncryptionKey(password);
+ } catch (Exception e) {
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+ } else {
+ if (!KeycloakInstalled.console().confirm("KC_SESSION_KEY env var already set. Do you want to use this as your local encryption key? (y/n): ")) {
+ KeycloakInstalled.console().writer().println("Unset KC_SESSION_KEY env var and run again");
+ System.exit(1);
+ }
+ this.encrypted = true;
+ this.encryptionKey = getEncryptionKey();
+ }
+ String server = KeycloakInstalled.console().readLine("Authentication server URL [http://localhost:8080/auth]: ").trim();
+ String realm = KeycloakInstalled.console().readLine("Name of realm [master]: ").trim();
+ String client = KeycloakInstalled.console().readLine("CLI client id [kcinit]: ").trim();
+ String secret = KeycloakInstalled.console().readLine("CLI client secret [none]: ").trim();
+ if (server.equals("")) {
+ server = "http://localhost:8080/auth";
+ }
+ if (realm.equals("")) {
+ realm = "master";
+ }
+ if (client.equals("")) {
+ client = "kcinit";
+ }
+ File configDir = getTokenDirectory();
+ configDir.mkdirs();
+
+ File configFile = getConfigFile();
+ Map<String, String> props = new HashMap<>();
+ props.put("server", server);
+ props.put("realm", realm);
+ props.put("client", client);
+ props.put("secret", secret);
+
+ try {
+ String json = JsonSerialization.writeValueAsString(props);
+ writeFile(configFile, json);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ KeycloakInstalled.console().writer().println();
+ KeycloakInstalled.console().writer().println("Installation complete!");
+ KeycloakInstalled.console().writer().println();
+ }
+
+
+ public void printHelp() {
+ KeycloakInstalled.console().writer().println("Commands:");
+ KeycloakInstalled.console().writer().println(" no arguments is a login");
+ KeycloakInstalled.console().writer().println(" no argument with -f forces login");
+ KeycloakInstalled.console().writer().println(" logout");
+ KeycloakInstalled.console().writer().println(" token [client] - print access token of desired client. Defaults to default master client. Will print either 'error', 'not-allowed', or 'login-required' on error.");
+ KeycloakInstalled.console().writer().println(" install - Install this utility. Will store in $HOME/.keycloak/kcinit unless " + KC_LOGIN_CONFIG_PATH + " env var is set");
+ System.exit(1);
+ }
+
+
+ public AdapterConfig getConfig() {
+ File configFile = getConfigFile();
+ if (!configFile.exists()) {
+ KeycloakInstalled.console().writer().println("You have not configured kcinit. Please run 'kcinit install' to configure.");
+ System.exit(1);
+ return null;
+ }
+
+ AdapterConfig config = new AdapterConfig();
+ config.setAuthServerUrl((String) getConfigProperties().get("server"));
+ config.setRealm((String) getConfigProperties().get("realm"));
+ config.setResource((String) getConfigProperties().get("client"));
+ config.setSslRequired("external");
+ String secret = (String) getConfigProperties().get("secret");
+ if (secret != null && !secret.trim().equals("")) {
+ Map<String, Object> creds = new HashMap<>();
+ creds.put("secret", secret);
+ config.setCredentials(creds);
+ } else {
+ config.setPublicClient(true);
+ }
+ return config;
+ }
+
+ private Map<String, String> getConfigProperties() {
+ if (this.config != null) return this.config;
+ if (!getConfigFile().exists()) {
+ KeycloakInstalled.console().writer().println();
+ KeycloakInstalled.console().writer().println(("Config file does not exist. Run kcinit install to set it up."));
+ System.exit(1);
+ }
+ String json = readFile(getConfigFile());
+ try {
+ Map map = JsonSerialization.readValue(json, Map.class);
+ config = (Map<String, String>) map;
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return this.config;
+ }
+
+ public String readToken(String client) throws Exception {
+ String json = getTokenResponse(client);
+ if (json == null) return null;
+
+
+ if (json != null) {
+ try {
+ AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class);
+ if (Time.currentTime() < tokenResponse.getExpiresIn()) {
+ return tokenResponse.getToken();
+ }
+ AdapterConfig config = getConfig();
+ KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config));
+ installed.refreshToken(tokenResponse.getRefreshToken());
+ processResponse(installed, client);
+ return tokenResponse.getToken();
+ } catch (Exception e) {
+ File tokenFile = getTokenFilePath(client);
+ if (tokenFile.exists()) {
+ tokenFile.delete();
+ }
+
+ return null;
+ }
+ }
+ return null;
+
+ }
+
+ public String readRefreshToken(String client) throws Exception {
+ String json = getTokenResponse(client);
+ if (json == null) return null;
+
+
+ if (json != null) {
+ try {
+ AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class);
+ return tokenResponse.getRefreshToken();
+ } catch (Exception e) {
+ if (debug) {
+ e.printStackTrace();
+ }
+ File tokenFile = getTokenFilePath(client);
+ if (tokenFile.exists()) {
+ tokenFile.delete();
+ }
+
+ return null;
+ }
+ }
+ return null;
+
+ }
+
+
+ private String getTokenResponse(String client) throws IOException {
+ File tokenFile = getTokenFilePath(client);
+ try {
+ return readFile(tokenFile);
+ } catch (Exception e) {
+ if (debug) {
+ System.err.println("Failed to read encrypted file");
+ e.printStackTrace();
+ }
+ if (tokenFile.exists()) tokenFile.delete();
+ return null;
+ }
+ }
+
+
+ public void token() throws Exception {
+ KeycloakInstalled.console().stderrOutput();
+
+ checkEnv();
+ String masterClient = getMasterClient();
+ String client = masterClient;
+ if (args.length > 1) {
+ client = args[1];
+ }
+ //System.err.println("readToken: " + client);
+ String token = readToken(client);
+ if (token != null) {
+ System.out.print(token);
+ return;
+ }
+ if (token == null && client.equals(masterClient)) {
+ //System.err.println("not logged in, logging in.");
+ doConsoleLogin();
+ token = readToken(client);
+ if (token != null) {
+ System.out.print(token);
+ return;
+ }
+
+ }
+ String masterToken = readToken(masterClient);
+ if (masterToken == null) {
+ //System.err.println("not logged in, logging in.");
+ doConsoleLogin();
+ masterToken = readToken(masterClient);
+ if (masterToken == null) {
+ System.err.println("Login failed. Cannot retrieve token");
+ System.exit(1);
+ }
+ }
+
+ //System.err.println("exchange: " + client);
+ Client httpClient = getHttpClient();
+
+ WebTarget exchangeUrl = httpClient.target(getServer())
+ .path("/realms")
+ .path(getRealm())
+ .path("protocol/openid-connect/token");
+
+ Form form = new Form()
+ .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
+ .param(OAuth2Constants.CLIENT_ID, masterClient)
+ .param(OAuth2Constants.SUBJECT_TOKEN, masterToken)
+ .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
+ .param(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)
+ .param(OAuth2Constants.AUDIENCE, client);
+ if (getMasterClientSecret() != null) {
+ form.param(OAuth2Constants.CLIENT_SECRET, getMasterClientSecret());
+ }
+ Response response = exchangeUrl.request().post(Entity.form(
+ form
+ ));
+
+ if (response.getStatus() == 401 || response.getStatus() == 403) {
+ response.close();
+ System.err.println("Not allowed to exchange for client token");
+ System.exit(1);
+ }
+
+ if (response.getStatus() != 200) {
+ if (response.getMediaType() != null && response.getMediaType().equals(MediaType.APPLICATION_JSON_TYPE)) {
+ try {
+ String json = response.readEntity(String.class);
+ OAuth2ErrorRepresentation error = JsonSerialization.readValue(json, OAuth2ErrorRepresentation.class);
+ System.err.println("Failed to exchange token: " + error.getError() + ". " + error.getErrorDescription());
+ System.exit(1);
+ } catch (Exception ignore) {
+ ignore.printStackTrace();
+
+ }
+ }
+
+ response.close();
+ System.err.println("Unknown error exchanging for client token: " + response.getStatus());
+ System.exit(1);
+ }
+
+ String json = response.readEntity(String.class);
+ response.close();
+ AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class);
+ if (tokenResponse.getToken() != null) {
+ getTokenDirectory().mkdirs();
+ tokenResponse.setExpiresIn(Time.currentTime() + tokenResponse.getExpiresIn());
+ tokenResponse.setIdToken(null);
+ json = JsonSerialization.writeValueAsString(tokenResponse);
+ writeFile(getTokenFilePath(client), json);
+ System.out.printf(tokenResponse.getToken());
+ } else {
+ System.err.println("Error processing token");
+ System.exit(1);
+ }
+ }
+
+ protected String getMasterClientSecret() {
+ return getProperty("secret");
+ }
+
+ protected String getServer() {
+ return getProperty("server");
+ }
+
+ protected String getRealm() {
+ return getProperty("realm");
+ }
+
+ public String getProperty(String name) {
+ return (String) getConfigProperties().get(name);
+ }
+
+ protected boolean forceLogin() {
+ return args.length > 0 && args[0].equals("-f");
+
+ }
+
+ public Client getHttpClient() {
+ return new ResteasyClientBuilder().disableTrustManager().build();
+ }
+
+ public void login() throws Exception {
+ checkEnv();
+ String masterClient = getMasterClient();
+ if (!forceLogin() && readToken(masterClient) != null) {
+ KeycloakInstalled.console().writer().println("Already logged in. `kcinit -f` to force relogin");
+ return;
+ }
+ doConsoleLogin();
+ KeycloakInstalled.console().writer().println("Login successful!");
+ }
+
+ public void doConsoleLogin() throws Exception {
+ String masterClient = getMasterClient();
+ AdapterConfig config = getConfig();
+ KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config));
+ //System.err.println("calling loginCommandLine");
+ if (!installed.loginCommandLine()) {
+ System.exit(1);
+ }
+ processResponse(installed, masterClient);
+ }
+
+ private String getMasterClient() {
+ return getProperty("client");
+ }
+
+ private void processResponse(KeycloakInstalled installed, String client) throws IOException {
+ AccessTokenResponse tokenResponse = installed.getTokenResponse();
+ tokenResponse.setExpiresIn(Time.currentTime() + tokenResponse.getExpiresIn());
+ tokenResponse.setIdToken(null);
+ String json = JsonSerialization.writeValueAsString(tokenResponse);
+ getTokenDirectory().mkdirs();
+ writeFile(getTokenFilePath(client), json);
+ }
+
+ public void logout() throws Exception {
+ String token = readRefreshToken(getMasterClient());
+ if (token != null) {
+ try {
+ KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(getConfig());
+ ServerRequest.invokeLogout(deployment, token);
+ } catch (Exception e) {
+ if (debug) {
+ e.printStackTrace();
+ }
+ }
+
+ }
+ if (getTokenDirectory().exists()) {
+ for (File fp : getTokenDirectory().listFiles()) fp.delete();
+ }
+ }
+ public void uninstall() throws Exception {
+ File configFile = getConfigFile();
+ if (configFile.exists()) configFile.delete();
+ if (getTokenDirectory().exists()) {
+ for (File fp : getTokenDirectory().listFiles()) fp.delete();
+ }
+ }
+}
diff --git a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java
index f1fee42..b0b8d0e 100644
--- a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java
+++ b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java
@@ -39,15 +39,7 @@ import javax.ws.rs.core.Form;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import java.awt.*;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
-import java.io.PrintStream;
-import java.io.PrintWriter;
-import java.io.PushbackInputStream;
-import java.io.Reader;
+import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
@@ -65,6 +57,7 @@ public class KeycloakInstalled {
public interface HttpResponseWriter {
void success(PrintWriter pw, KeycloakInstalled ki);
+
void failure(PrintWriter pw, KeycloakInstalled ki);
}
@@ -86,12 +79,12 @@ public class KeycloakInstalled {
private Locale locale;
private HttpResponseWriter loginResponseWriter;
private HttpResponseWriter logoutResponseWriter;
+ private ResteasyClient resteasyClient;
Pattern callbackPattern = Pattern.compile("callback\\s*=\\s*\"([^\"]+)\"");
Pattern paramPattern = Pattern.compile("param=\"([^\"]+)\"\\s+label=\"([^\"]+)\"\\s+mask=(\\S+)");
Pattern codePattern = Pattern.compile("code=([^&]+)");
-
public KeycloakInstalled() {
InputStream config = Thread.currentThread().getContextClassLoader().getResourceAsStream(KEYCLOAK_JSON);
deployment = KeycloakDeploymentBuilder.build(config);
@@ -179,6 +172,10 @@ public class KeycloakInstalled {
this.logoutResponseWriter = logoutResponseWriter;
}
+ public void setResteasyClient(ResteasyClient resteasyClient) {
+ this.resteasyClient = resteasyClient;
+ }
+
public Locale getLocale() {
return locale;
}
@@ -302,6 +299,139 @@ public class KeycloakInstalled {
status = Status.LOGGED_MANUAL;
}
+ public static class Console {
+ protected java.io.Console console = System.console();
+ protected PrintWriter writer;
+ protected BufferedReader reader;
+
+ static Console SINGLETON = new Console();
+
+ private Console() {
+ }
+
+
+ public PrintWriter writer() {
+ if (console == null) {
+ if (writer == null) {
+ writer = new PrintWriter(System.err, true);
+ }
+ return writer;
+ }
+ return console.writer();
+ }
+
+ public Reader reader() {
+ if (console == null) {
+ return getReader();
+ }
+ return console.reader();
+ }
+
+ protected BufferedReader getReader() {
+ if (reader != null) return reader;
+ reader = new BufferedReader(new BufferedReader(new InputStreamReader(System.in)));
+ return reader;
+ }
+
+ public Console format(String fmt, Object... args) {
+ if (console == null) {
+ writer().format(fmt, args);
+ return this;
+ }
+ console.format(fmt, args);
+ return this;
+ }
+
+ public Console printf(String format, Object... args) {
+ if (console == null) {
+ writer().printf(format, args);
+ return this;
+ }
+ console.printf(format, args);
+ return this;
+ }
+
+ public String readLine(String fmt, Object... args) {
+ if (console == null) {
+ format(fmt, args);
+ return readLine();
+ }
+ return console.readLine(fmt, args);
+ }
+
+ public boolean confirm(String fmt, Object... args) {
+ String prompt = "";
+ while (!"y".equals(prompt) && !"n".equals(prompt)) {
+ prompt = readLine(fmt, args);
+ }
+ return "y".equals(prompt);
+
+ }
+
+ public String prompt(String fmt, Object... args) {
+ String prompt = "";
+ while (prompt.equals("")) {
+ prompt = readLine(fmt, args).trim();
+ }
+ return prompt;
+
+ }
+
+ public String passwordPrompt(String fmt, Object... args) {
+ String prompt = "";
+ while (prompt.equals("")) {
+ char[] val = readPassword(fmt, args);
+ prompt = new String(val);
+ prompt = prompt.trim();
+ }
+ return prompt;
+
+ }
+
+ public String readLine() {
+ if (console == null) {
+ try {
+ return getReader().readLine();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return console.readLine();
+ }
+
+ public char[] readPassword(String fmt, Object... args) {
+ if (console == null) {
+ return readLine(fmt, args).toCharArray();
+
+ }
+ return console.readPassword(fmt, args);
+ }
+
+ public char[] readPassword() {
+ if (console == null) {
+ return readLine().toCharArray();
+ }
+ return console.readPassword();
+ }
+
+ public void flush() {
+ if (console == null) {
+ System.err.flush();
+ return;
+ }
+ console.flush();
+ }
+
+ public void stderrOutput() {
+ //System.err.println("not using System.console()");
+ console = null;
+ }
+ }
+
+ public static Console console() {
+ return Console.SINGLETON;
+ }
+
public boolean loginCommandLine() throws IOException, ServerRequest.HttpFailure, VerificationException {
String redirectUri = "urn:ietf:wg:oauth:2.0:oob";
@@ -309,7 +439,6 @@ public class KeycloakInstalled {
}
-
/**
* Experimental proprietary WWW-Authentication challenge protocol.
* WWW-Authentication: X-Text-Form-Challenge callback="{url}" param="{param-name}" label="{param-display-label}"
@@ -325,62 +454,117 @@ public class KeycloakInstalled {
.queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE)
.queryParam(OAuth2Constants.CLIENT_ID, deployment.getResourceName())
.queryParam(OAuth2Constants.REDIRECT_URI, redirectUri)
+ .queryParam("display", "console")
.queryParam(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID)
.build().toString();
- ResteasyClient client = new ResteasyClientBuilder().disableTrustManager().build();
+ ResteasyClient client = createResteasyClient();
try {
+ //System.err.println("initial request");
Response response = client.target(authUrl).request().get();
- if (response.getStatus() != 401) {
- return false;
- }
while (true) {
- String authenticationHeader = response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE);
- if (authenticationHeader == null) {
- return false;
- }
- if (!authenticationHeader.contains("X-Text-Form-Challenge")) {
- return false;
- }
- if (response.getMediaType() != null) {
- String splash = response.readEntity(String.class);
- System.console().writer().println(splash);
- }
- Matcher m = callbackPattern.matcher(authenticationHeader);
- if (!m.find()) return false;
- String callback = m.group(1);
- //System.err.println("callback: " + callback);
- m = paramPattern.matcher(authenticationHeader);
- Form form = new Form();
- while (m.find()) {
- String param = m.group(1);
- String label = m.group(2);
- String mask = m.group(3).trim();
- boolean maskInput = mask.equals("true");
- String value = null;
- if (maskInput) {
- char[] txt = System.console().readPassword(label);
- value = new String(txt);
+ System.err.println("looping");
+
+ if (response.getStatus() == 403) {
+ if (response.getMediaType() != null) {
+ String splash = response.readEntity(String.class);
+ console().writer().println(splash);
} else {
- value = System.console().readLine(label);
+ System.err.println("Forbidden to login");
+ }
+ } else if (response.getStatus() == 401) {
+ String authenticationHeader = response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE);
+ if (authenticationHeader == null) {
+ System.err.println("Failure: Invalid protocol. No WWW-Authenticate header");
+ return false;
+ }
+ //System.err.println("got header: " + authenticationHeader);
+ if (!authenticationHeader.contains("X-Text-Form-Challenge")) {
+ System.err.println("Failure: Invalid WWW-Authenticate header.");
+ return false;
}
- form.param(param, value);
+ if (response.getMediaType() != null) {
+ String splash = response.readEntity(String.class);
+ console().writer().println(splash);
+ } else {
+ response.close();
+ }
+ Matcher m = callbackPattern.matcher(authenticationHeader);
+ if (!m.find()) {
+ System.err.println("Failure: Invalid WWW-Authenticate header.");
+ return false;
+ }
+ String callback = m.group(1);
+ //System.err.println("callback: " + callback);
+ m = paramPattern.matcher(authenticationHeader);
+ Form form = new Form();
+ while (m.find()) {
+ String param = m.group(1);
+ String label = m.group(2);
+ String mask = m.group(3).trim();
+ boolean maskInput = mask.equals("true");
+ String value = null;
+ if (maskInput) {
+ char[] txt = console().readPassword(label);
+ value = new String(txt);
+ } else {
+ value = console().readLine(label);
+ }
+ form.param(param, value);
+ }
+ response.close();
+ client.close();
+ client = createResteasyClient();
+ response = client.target(callback).request().post(Entity.form(form));
+ } else if (response.getStatus() == 302) {
+ int redirectCount = 0;
+ do {
+ String location = response.getLocation().toString();
+ Matcher m = codePattern.matcher(location);
+ if (!m.find()) {
+ response.close();
+ client.close();
+ client = createResteasyClient();
+ response = client.target(location).request().get();
+ } else {
+ response.close();
+ client.close();
+ String code = m.group(1);
+ processCode(code, redirectUri);
+ return true;
+ }
+ if (response.getStatus() == 302 && redirectCount++ > 4) {
+ System.err.println("Too many redirects. Aborting");
+ return false;
+ }
+ } while (response.getStatus() == 302);
+ } else {
+ System.err.println("Unknown response from server: " + response.getStatus());
+ return false;
}
- response = client.target(callback).request().post(Entity.form(form));
- if (response.getStatus() == 401) continue;
- if (response.getStatus() != 302) return false;
- String location = response.getLocation().toString();
- m = codePattern.matcher(location);
- if (!m.find()) return false;
- String code = m.group(1);
- processCode(code, redirectUri);
- return true;
}
+ } catch (Exception ex) {
+ throw ex;
} finally {
client.close();
}
}
+ protected ResteasyClient getResteasyClient() {
+ if (this.resteasyClient == null) {
+ this.resteasyClient = createResteasyClient();
+ }
+ return this.resteasyClient;
+ }
+
+ protected ResteasyClient createResteasyClient() {
+ return new ResteasyClientBuilder()
+ .connectionCheckoutTimeout(1, TimeUnit.HOURS)
+ .connectionTTL(1, TimeUnit.HOURS)
+ .socketTimeout(1, TimeUnit.HOURS)
+ .disableTrustManager().build();
+ }
+
public String getTokenString() throws VerificationException, IOException, ServerRequest.HttpFailure {
return tokenString;
@@ -400,7 +584,7 @@ public class KeycloakInstalled {
parseAccessToken(tokenResponse);
}
- public void refreshToken(String refreshToken) throws IOException, ServerRequest.HttpFailure, VerificationException {
+ public void refreshToken(String refreshToken) throws IOException, ServerRequest.HttpFailure, VerificationException {
AccessTokenResponse tokenResponse = ServerRequest.invokeRefresh(deployment, refreshToken);
parseAccessToken(tokenResponse);
@@ -452,7 +636,6 @@ public class KeycloakInstalled {
}
-
private void processCode(String code, String redirectUri) throws IOException, ServerRequest.HttpFailure, VerificationException {
AccessTokenResponse tokenResponse = ServerRequest.invokeAccessCodeToToken(deployment, code, redirectUri, null);
parseAccessToken(tokenResponse);
@@ -474,86 +657,6 @@ public class KeycloakInstalled {
return sb.toString();
}
- public static class MaskingThread extends Thread {
- private volatile boolean stop;
- private char echochar = '*';
-
- public MaskingThread() {
- }
-
- /**
- * Begin masking until asked to stop.
- */
- public void run() {
-
- int priority = Thread.currentThread().getPriority();
- Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
-
- try {
- stop = true;
- while(stop) {
- System.out.print("\010" + echochar);
- try {
- // attempt masking at this rate
- Thread.currentThread().sleep(1);
- }catch (InterruptedException iex) {
- Thread.currentThread().interrupt();
- return;
- }
- }
- } finally { // restore the original priority
- Thread.currentThread().setPriority(priority);
- }
- }
-
- /**
- * Instruct the thread to stop masking.
- */
- public void stopMasking() {
- this.stop = false;
- }
- }
-
- public static String readMasked(Reader reader) {
- MaskingThread et = new MaskingThread();
- Thread mask = new Thread(et);
- mask.start();
-
- BufferedReader in = new BufferedReader(reader);
- String password = "";
-
- try {
- password = in.readLine();
- } catch (IOException ioe) {
- ioe.printStackTrace();
- }
- // stop masking
- et.stopMasking();
- // return the password entered by the user
- return password;
- }
-
- private String readLine(Reader reader, boolean mask) throws IOException {
- if (mask) {
- System.out.print(" ");
- return readMasked(reader);
- }
-
- StringBuilder sb = new StringBuilder();
-
- char cb[] = new char[1];
- while (reader.read(cb) != -1) {
- char c = cb[0];
- if ((c == '\n') || (c == '\r')) {
- break;
- } else {
- sb.append(c);
- }
- }
-
- return sb.toString();
- }
-
public class CallbackListener extends Thread {
adapters/oidc/kcinit/src/main/bin/kcinit 26(+26 -0)
diff --git a/adapters/oidc/kcinit/src/main/bin/kcinit b/adapters/oidc/kcinit/src/main/bin/kcinit
new file mode 100755
index 0000000..4f5c2c6
--- /dev/null
+++ b/adapters/oidc/kcinit/src/main/bin/kcinit
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+case "`uname`" in
+ CYGWIN*)
+ CFILE = `cygpath "$0"`
+ RESOLVED_NAME=`readlink -f "$CFILE"`
+ ;;
+ Darwin*)
+ RESOLVED_NAME=`readlink "$0"`
+ ;;
+ FreeBSD)
+ RESOLVED_NAME=`readlink -f "$0"`
+ ;;
+ Linux)
+ RESOLVED_NAME=`readlink -f "$0"`
+ ;;
+esac
+
+if [ "x$RESOLVED_NAME" = "x" ]; then
+ RESOLVED_NAME="$0"
+fi
+
+SCRIPTPATH=`dirname "$RESOLVED_NAME"`
+JAR=$SCRIPTPATH/kcinit-${project.version}.jar
+
+java -jar $JAR $@
diff --git a/adapters/oidc/kcinit/src/main/bin/kcinit.bat b/adapters/oidc/kcinit/src/main/bin/kcinit.bat
new file mode 100755
index 0000000..9055309
--- /dev/null
+++ b/adapters/oidc/kcinit/src/main/bin/kcinit.bat
@@ -0,0 +1,8 @@
+@echo off
+
+if "%OS%" == "Windows_NT" (
+ set "DIRNAME=%~dp0%"
+) else (
+ set DIRNAME=.\
+)
+java -jar %DIRNAME%\kcinit-${project.version}.jar %*
adapters/oidc/kcinit-dist/assembly.xml 49(+49 -0)
diff --git a/adapters/oidc/kcinit-dist/assembly.xml b/adapters/oidc/kcinit-dist/assembly.xml
new file mode 100755
index 0000000..f48c714
--- /dev/null
+++ b/adapters/oidc/kcinit-dist/assembly.xml
@@ -0,0 +1,49 @@
+<!--
+ ~ Copyright 2016 Red Hat, Inc. and/or its affiliates
+ ~ and other contributors as indicated by the @author tags.
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+<assembly>
+ <id>kcinit-dist</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <includeBaseDirectory>false</includeBaseDirectory>
+
+ <files>
+ <file>
+ <source>../kcinit/src/main/bin/kcinit</source>
+ <outputDirectory>kcinit</outputDirectory>
+ <fileMode>0755</fileMode>
+ <filtered>true</filtered>
+ </file>
+ <file>
+ <source>../kcinit/src/main/bin/kcinit.bat</source>
+ <outputDirectory>kcinit</outputDirectory>
+ <filtered>true</filtered>
+ </file>
+ </files>
+ <dependencySets>
+ <dependencySet>
+ <includes>
+ <include>org.keycloak:kcinit</include>
+ </includes>
+ <outputDirectory>kcinit</outputDirectory>
+ </dependencySet>
+ </dependencySets>
+
+</assembly>
adapters/oidc/kcinit-dist/pom.xml 69(+69 -0)
diff --git a/adapters/oidc/kcinit-dist/pom.xml b/adapters/oidc/kcinit-dist/pom.xml
new file mode 100755
index 0000000..effdbb8
--- /dev/null
+++ b/adapters/oidc/kcinit-dist/pom.xml
@@ -0,0 +1,69 @@
+<!--
+ ~ Copyright 2016 Red Hat, Inc. and/or its affiliates
+ ~ and other contributors as indicated by the @author tags.
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+<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/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <artifactId>keycloak-client-cli-parent</artifactId>
+ <groupId>org.keycloak</groupId>
+ <version>4.0.0.CR1-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>kcinit-dist</artifactId>
+ <packaging>pom</packaging>
+ <name>Kcinit Distribution</name>
+ <description/>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.keycloak</groupId>
+ <artifactId>kcinit</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <finalName>kcinit-${project.version}</finalName>
+ <plugins>
+ <plugin>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>assemble</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ <configuration>
+ <descriptors>
+ <descriptor>assembly.xml</descriptor>
+ </descriptors>
+ <outputDirectory>
+ target
+ </outputDirectory>
+ <workDirectory>
+ target/assembly/work
+ </workDirectory>
+ <appendAssemblyId>false</appendAssemblyId>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
adapters/oidc/pom.xml 3(+2 -1)
diff --git a/adapters/oidc/pom.xml b/adapters/oidc/pom.xml
index 4252253..228cbde 100755
--- a/adapters/oidc/pom.xml
+++ b/adapters/oidc/pom.xml
@@ -34,7 +34,8 @@
<module>adapter-core</module>
<module>as7-eap6</module>
<module>installed</module>
- <module>cli-sso</module>
+ <module>kcinit</module>
+ <module>kcinit-dist</module>
<module>jaxrs-oauth-client</module>
<module>jetty</module>
<module>js</module>
diff --git a/common/src/main/java/org/keycloak/common/util/RandomString.java b/common/src/main/java/org/keycloak/common/util/RandomString.java
new file mode 100644
index 0000000..70ce02d
--- /dev/null
+++ b/common/src/main/java/org/keycloak/common/util/RandomString.java
@@ -0,0 +1,66 @@
+package org.keycloak.common.util;
+
+import java.security.SecureRandom;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Random;
+
+public class RandomString {
+
+ /**
+ * Generate a random string.
+ */
+ public String nextString() {
+ for (int idx = 0; idx < buf.length; ++idx)
+ buf[idx] = symbols[random.nextInt(symbols.length)];
+ return new String(buf);
+ }
+
+ public static final String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ public static final String lower = upper.toLowerCase(Locale.ROOT);
+
+ public static final String digits = "0123456789";
+
+ public static final String alphanum = upper + lower + digits;
+
+ private final Random random;
+
+ private final char[] symbols;
+
+ private final char[] buf;
+
+ public RandomString(int length, Random random, String symbols) {
+ if (length < 1) throw new IllegalArgumentException();
+ if (symbols.length() < 2) throw new IllegalArgumentException();
+ this.random = Objects.requireNonNull(random);
+ this.symbols = symbols.toCharArray();
+ this.buf = new char[length];
+ }
+
+ /**
+ * Create an alphanumeric string generator.
+ */
+ public RandomString(int length, Random random) {
+ this(length, random, alphanum);
+ }
+
+ /**
+ * Create an alphanumeric strings from a secure generator.
+ */
+ public RandomString(int length) {
+ this(length, new SecureRandom());
+ }
+
+ /**
+ * Create session identifiers.
+ */
+ public RandomString() {
+ this(21);
+ }
+
+ public static String randomCode(int length) {
+ return new RandomString(length).nextString();
+ }
+
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/keycloak/jose/jwe/JWE.java b/core/src/main/java/org/keycloak/jose/jwe/JWE.java
index 75759dd..8d954ea 100644
--- a/core/src/main/java/org/keycloak/jose/jwe/JWE.java
+++ b/core/src/main/java/org/keycloak/jose/jwe/JWE.java
@@ -18,13 +18,23 @@
package org.keycloak.jose.jwe;
import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import org.keycloak.common.util.Base64;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.BouncyIntegration;
import org.keycloak.jose.jwe.alg.JWEAlgorithmProvider;
import org.keycloak.jose.jwe.enc.JWEEncryptionProvider;
import org.keycloak.util.JsonSerialization;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@@ -193,4 +203,66 @@ public class JWE {
}
}
+ public static String encryptUTF8(String password, String saltString, String payload) {
+ byte[] bytes = null;
+ try {
+ bytes = payload.getBytes("UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ return encrypt(password, saltString, bytes);
+
+ }
+
+
+ public static String encrypt(String password, String saltString, byte[] payload) {
+ try {
+ byte[] salt = Base64.decode(saltString);
+ SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 100, 128);
+ SecretKey tmp = factory.generateSecret(spec);
+ SecretKey aesKey = new SecretKeySpec(tmp.getEncoded(), "AES");
+
+ JWEHeader jweHeader = new JWEHeader(JWEConstants.A128KW, JWEConstants.A128CBC_HS256, null);
+ JWE jwe = new JWE()
+ .header(jweHeader)
+ .content(payload);
+
+ jwe.getKeyStorage()
+ .setEncryptionKey(aesKey);
+
+ return jwe.encodeJwe();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static byte[] decrypt(String password, String saltString, String encodedJwe) {
+ try {
+ byte[] salt = Base64.decode(saltString);
+ SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 100, 128);
+ SecretKey tmp = factory.generateSecret(spec);
+ SecretKey aesKey = new SecretKeySpec(tmp.getEncoded(), "AES");
+
+ JWE jwe = new JWE();
+ jwe.getKeyStorage()
+ .setEncryptionKey(aesKey);
+
+ jwe.verifyAndDecodeJwe(encodedJwe);
+ return jwe.getContent();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static String decryptUTF8(String password, String saltString, String encodedJwe) {
+ byte[] payload = decrypt(password, saltString, encodedJwe);
+ try {
+ return new String(payload, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
}
diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java
index df54112..efa9ed8 100644
--- a/core/src/main/java/org/keycloak/OAuth2Constants.java
+++ b/core/src/main/java/org/keycloak/OAuth2Constants.java
@@ -34,6 +34,8 @@ public interface OAuth2Constants {
String REDIRECT_URI = "redirect_uri";
+ String DISPLAY = "display";
+
String SCOPE = "scope";
String STATE = "state";
diff --git a/core/src/test/java/org/keycloak/jose/JWETest.java b/core/src/test/java/org/keycloak/jose/JWETest.java
index 31d8a8a..cc179bf 100644
--- a/core/src/test/java/org/keycloak/jose/JWETest.java
+++ b/core/src/test/java/org/keycloak/jose/JWETest.java
@@ -19,18 +19,18 @@ package org.keycloak.jose;
import java.io.UnsupportedEncodingException;
import java.security.Key;
+import java.security.spec.KeySpec;
import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.junit.Assert;
import org.junit.Test;
+import org.keycloak.common.util.Base64;
import org.keycloak.common.util.Base64Url;
-import org.keycloak.jose.jwe.JWE;
-import org.keycloak.jose.jwe.JWEConstants;
-import org.keycloak.jose.jwe.JWEException;
-import org.keycloak.jose.jwe.JWEHeader;
-import org.keycloak.jose.jwe.JWEKeyStorage;
+import org.keycloak.jose.jwe.*;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@@ -53,7 +53,6 @@ public class JWETest {
testDirectEncryptAndDecrypt(aesKey, hmacKey, JWEConstants.A128CBC_HS256, PAYLOAD, true);
}
-
// Works just on OpenJDK 8. Other JDKs (IBM, Oracle) have restrictions on maximum key size of AES to be 128
// @Test
public void testDirect_Aes256CbcHmacSha512() throws Exception {
@@ -119,9 +118,24 @@ public class JWETest {
}
@Test
+ public void testPassword() throws Exception {
+ byte[] salt = JWEUtils.generateSecret(8);
+ String encodedSalt = Base64.encodeBytes(salt);
+ String jwe = JWE.encryptUTF8("geheim", encodedSalt, PAYLOAD);
+ String decodedContent = JWE.decryptUTF8("geheim", encodedSalt, jwe);
+ Assert.assertEquals(PAYLOAD, decodedContent);
+ }
+
+
+
+ @Test
public void testAesKW_Aes128CbcHmacSha256() throws Exception {
SecretKey aesKey = new SecretKeySpec(AES_128_KEY, "AES");
+ testAesKW_Aes128CbcHmacSha256(aesKey);
+ }
+
+ private void testAesKW_Aes128CbcHmacSha256(SecretKey aesKey) throws UnsupportedEncodingException, JWEException {
JWEHeader jweHeader = new JWEHeader(JWEConstants.A128KW, JWEConstants.A128CBC_HS256, null);
JWE jwe = new JWE()
.header(jweHeader)
@@ -146,6 +160,15 @@ public class JWETest {
Assert.assertEquals(PAYLOAD, decodedContent);
}
+ @Test
+ public void testSalt() {
+ byte[] random = JWEUtils.generateSecret(8);
+ System.out.print("new byte[] = {");
+ for (byte b : random) {
+ System.out.print(""+Byte.toString(b)+",");
+ }
+ }
+
@Test
public void externalJweAes128CbcHmacSha256Test() throws UnsupportedEncodingException, JWEException {
pom.xml 11(+11 -0)
diff --git a/pom.xml b/pom.xml
index a82b1a2..207bb36 100755
--- a/pom.xml
+++ b/pom.xml
@@ -1404,6 +1404,17 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.keycloak</groupId>
+ <artifactId>kcinit</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.keycloak</groupId>
+ <artifactId>kcinit-dist</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
</dependencies>
</dependencyManagement>
diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/DisplayUtils.java b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayUtils.java
new file mode 100644
index 0000000..6a393a3
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayUtils.java
@@ -0,0 +1,20 @@
+package org.keycloak.authentication;
+
+import org.keycloak.OAuth2Constants;
+
+/**
+ * Determine OIDC display parameter type.
+ *
+ */
+public class DisplayUtils {
+
+ public static boolean isConsole(AuthenticationFlowContext context) {
+ String displayParam = context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY);
+ return displayParam != null && displayParam.equals("console");
+ }
+ public static boolean isConsole(RequiredActionContext context) {
+ String displayParam = context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY);
+ return displayParam != null && displayParam.equals("console");
+ }
+
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java
index caaa14e..1289842 100755
--- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java
+++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java
@@ -60,6 +60,15 @@ public interface RequiredActionContext {
URI getActionUrl();
/**
+ * Get the action URL for the required action. This auto-generates the access code.
+ *
+ * @param authSessionIdParam if true, will embed session id as query param. Useful for clients that don't support cookies (i.e. console)
+ *
+ * @return
+ */
+ URI getActionUrl(boolean authSessionIdParam);
+
+ /**
* Create a Freemarker form builder that presets the user, action URI, and a generated access code
*
* @return
diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/TextChallenge.java b/server-spi-private/src/main/java/org/keycloak/authentication/TextChallenge.java
new file mode 100644
index 0000000..46c0707
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/authentication/TextChallenge.java
@@ -0,0 +1,288 @@
+package org.keycloak.authentication;
+
+import org.keycloak.forms.login.LoginFormsProvider;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+/**
+ * This class encapsulates a proprietary HTTP challenge protocol designed by keycloak team which is used by text-based console
+ * clients to dynamically render and prompt for information in a textual manner. The class is a builder which can
+ * build the challenge response (the header and response body).
+ *
+ * When doing code to token flow in OAuth, server could respond with
+ *
+ * 401
+ * WWW-Authenticate: X-Text-Form-Challenge callback="http://localhost/..."
+ * param="username" label="Username: " mask=false
+ * param="password" label="Password: " mask=true
+ * Content-Type: text/plain
+ *
+ * Please login with your username and password
+ *
+ *
+ * The client receives this challenge. It first outputs whatever the text body of the message contains. It will
+ * then prompt for username and password using the label values as prompt messages for each parameter.
+ *
+ * After the input has been entered by the user, the client does a form POST to the callback url with the values of the
+ * input parameters entered.
+ *
+ * The server can challenge with 401 as many times as it wants. The client will look for 302 responses. It will will
+ * follow all redirects unless the Location url has an OAuth "code" parameter. If there is a code parameter, then the
+ * client will stop and finish the OAuth flow to obtain a token. Any other response code other than 401 or 302 the client
+ * should abort with an error message.
+ *
+ */
+public class TextChallenge {
+
+ /**
+ * Build challenge response for required actions
+ *
+ * @param context
+ * @return
+ */
+ public static TextChallenge challenge(RequiredActionContext context) {
+ return new TextChallenge(context);
+
+ }
+
+ /**
+ * Build challenge response for authentication flows
+ *
+ * @param context
+ * @return
+ */
+ public static TextChallenge challenge(AuthenticationFlowContext context) {
+ return new TextChallenge(context);
+
+ }
+ /**
+ * Build challenge response header only for required actions
+ *
+ * @param context
+ * @return
+ */
+ public static HeaderBuilder header(RequiredActionContext context) {
+ return new TextChallenge(context).header();
+
+ }
+
+ /**
+ * Build challenge response header only for authentication flows
+ *
+ * @param context
+ * @return
+ */
+ public static HeaderBuilder header(AuthenticationFlowContext context) {
+ return new TextChallenge(context).header();
+
+ }
+ TextChallenge(RequiredActionContext requiredActionContext) {
+ this.requiredActionContext = requiredActionContext;
+ }
+
+ TextChallenge(AuthenticationFlowContext flowContext) {
+ this.flowContext = flowContext;
+ }
+
+
+ protected RequiredActionContext requiredActionContext;
+ protected AuthenticationFlowContext flowContext;
+ protected HeaderBuilder header;
+
+ /**
+ * Create a theme form pre-populated with challenge
+ *
+ * @return
+ */
+ public LoginFormsProvider form() {
+ if (header == null) throw new RuntimeException("Header Not Set");
+ return formInternal()
+ .setStatus(Response.Status.UNAUTHORIZED)
+ .setMediaType(MediaType.TEXT_PLAIN_TYPE)
+ .setResponseHeader(HttpHeaders.WWW_AUTHENTICATE, header.build());
+ }
+
+ /**
+ * Create challenge response with a body generated from localized
+ * message.properties of your theme
+ *
+ * @param msg message id
+ * @param params parameters to use to format the message
+ *
+ * @return
+ */
+ public Response message(String msg, String... params) {
+ if (header == null) throw new RuntimeException("Header Not Set");
+ Response response = Response.status(401)
+ .header(HttpHeaders.WWW_AUTHENTICATE, header.build())
+ .type(MediaType.TEXT_PLAIN)
+ .entity("\n" + formInternal().getMessage(msg, params) + "\n").build();
+ return response;
+ }
+
+ /**
+ * Create challenge response with a text message body
+ *
+ * @param text plain text of http response body
+ *
+ * @return
+ */
+ public Response text(String text) {
+ if (header == null) throw new RuntimeException("Header Not Set");
+ Response response = Response.status(401)
+ .header(HttpHeaders.WWW_AUTHENTICATE, header.build())
+ .type(MediaType.TEXT_PLAIN)
+ .entity("\n" + text + "\n").build();
+ return response;
+
+ }
+
+
+ /**
+ * Generate response with empty http response body
+ *
+ * @return
+ */
+ public Response response() {
+ if (header == null) throw new RuntimeException("Header Not Set");
+ Response response = Response.status(401)
+ .header(HttpHeaders.WWW_AUTHENTICATE, header.build()).build();
+ return response;
+
+ }
+
+
+
+ protected LoginFormsProvider formInternal() {
+ if (requiredActionContext != null) {
+ return requiredActionContext.form();
+ } else {
+ return flowContext.form();
+
+ }
+ }
+
+ /**
+ * Start building the header
+ *
+ * @return
+ */
+ public HeaderBuilder header() {
+ String callback;
+ if (requiredActionContext != null) {
+ callback = requiredActionContext.getActionUrl(true).toString();
+ } else {
+ callback = flowContext.getActionUrl(flowContext.generateAccessCode(), true).toString();
+
+ }
+ header = new HeaderBuilder(callback);
+ return header;
+ }
+
+ public class HeaderBuilder {
+ protected StringBuilder builder = new StringBuilder();
+
+ protected HeaderBuilder(String callback) {
+ builder.append("X-Text-Form-Challenge callback=\"").append(callback).append("\" ");
+ }
+
+ protected ParamBuilder param;
+
+ protected void checkParam() {
+ if (param != null) {
+ param.buildInternal();
+ param = null;
+ }
+ }
+
+ /**
+ * Build header string
+ *
+ * @return
+ */
+ public String build() {
+ checkParam();
+ return builder.toString();
+ }
+
+ /**
+ * Define a param
+ *
+ * @param name
+ * @return
+ */
+ public ParamBuilder param(String name) {
+ checkParam();
+ builder.append("param=\"").append(name).append("\" ");
+ param = new ParamBuilder(name);
+ return param;
+ }
+
+ public class ParamBuilder {
+ protected boolean mask;
+ protected String label;
+
+ protected ParamBuilder(String name) {
+ this.label = name;
+ }
+
+ public ParamBuilder label(String msg) {
+ this.label = formInternal().getMessage(msg);
+ return this;
+ }
+
+ public ParamBuilder labelText(String txt) {
+ this.label = txt;
+ return this;
+ }
+
+ /**
+ * Should input be masked by the client. For example, when entering password, you don't want to show password on console.
+ *
+ * @param mask
+ * @return
+ */
+ public ParamBuilder mask(boolean mask) {
+ this.mask = mask;
+ return this;
+ }
+
+ public void buildInternal() {
+ builder.append("label=\"").append(label).append(" \" ");
+ builder.append("mask=").append(mask).append(" ");
+ }
+
+ /**
+ * Build header string
+ *
+ * @return
+ */
+ public String build() {
+ return HeaderBuilder.this.build();
+ }
+
+ public TextChallenge challenge() {
+ return TextChallenge.this;
+ }
+
+ public LoginFormsProvider form() {
+ return TextChallenge.this.form();
+ }
+
+ public Response message(String msg, String... params) {
+ return TextChallenge.this.message(msg, params);
+ }
+
+ public Response text(String text) {
+ return TextChallenge.this.text(text);
+
+ }
+
+ public ParamBuilder param(String name) {
+ return HeaderBuilder.this.param(name);
+ }
+ }
+ }
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java b/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java
index a60ebc0..425ec52 100755
--- a/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java
+++ b/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java
@@ -23,6 +23,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
import org.keycloak.sessions.AuthenticationSessionModel;
+import java.util.List;
import java.util.Map;
/**
@@ -76,4 +77,24 @@ public interface EmailTemplateProvider extends Provider {
public void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException;
+ /**
+ * Send formatted email
+ *
+ * @param subjectFormatKey message property that will be used to format email subject
+ * @param bodyTemplate freemarker template file
+ * @param bodyAttributes attributes used to fill template
+ * @throws EmailException
+ */
+ void send(String subjectFormatKey, String bodyTemplate, Map<String, Object> bodyAttributes) throws EmailException;
+
+ /**
+ * Send formatted email
+ *
+ * @param subjectFormatKey message property that will be used to format email subject
+ * @param subjectAttributes attributes used to fill subject format message
+ * @param bodyTemplate freemarker template file
+ * @param bodyAttributes attributes used to fill template
+ * @throws EmailException
+ */
+ void send(String subjectFormatKey, List<Object> subjectAttributes, String bodyTemplate, Map<String, Object> bodyAttributes) throws EmailException;
}
diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
index 256b87f..31f430d 100755
--- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
+++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
@@ -54,6 +54,8 @@ public interface LoginFormsProvider extends Provider {
String getMessage(String message);
+ String getMessage(String message, String... parameters);
+
Response createLogin();
Response createPasswordReset();
diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java
index 5cb1ec5..ae2f4c7 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java
@@ -52,6 +52,7 @@ public interface Constants {
int DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT = 2592000;
String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
+ String VERIFY_EMAIL_CODE = "VERIFY_EMAIL_CODE";
String EXECUTION = "execution";
String CLIENT_ID = "client_id";
String TAB_ID = "tab_id";
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticator.java
index 5218347..170f9d7 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticator.java
@@ -18,6 +18,7 @@
package org.keycloak.authentication.authenticators.browser;
import org.jboss.logging.Logger;
+import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.constants.AdapterConstants;
@@ -25,10 +26,13 @@ import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.ClientSessionCode;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+import java.net.URI;
import java.util.List;
/**
@@ -66,8 +70,11 @@ public class IdentityProviderAuthenticator implements Authenticator {
String accessCode = new ClientSessionCode<>(context.getSession(), context.getRealm(), context.getAuthenticationSession()).getOrGenerateCode();
String clientId = context.getAuthenticationSession().getClient().getClientId();
String tabId = context.getAuthenticationSession().getTabId();
- Response response = Response.seeOther(
- Urls.identityProviderAuthnRequest(context.getUriInfo().getBaseUri(), providerId, context.getRealm().getName(), accessCode, clientId, tabId))
+ URI location = Urls.identityProviderAuthnRequest(context.getUriInfo().getBaseUri(), providerId, context.getRealm().getName(), accessCode, clientId, tabId);
+ if (context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY) != null) {
+ location = UriBuilder.fromUri(location).queryParam(OAuth2Constants.DISPLAY, context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY)).build();
+ }
+ Response response = Response.seeOther(location)
.build();
LOG.debugf("Redirecting to %s", providerId);
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java
index 9126689..955048b 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticator.java
@@ -20,6 +20,8 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.DisplayUtils;
+import org.keycloak.authentication.authenticators.console.ConsoleOTPFormAuthenticator;
import org.keycloak.events.Errors;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
@@ -39,11 +41,19 @@ import javax.ws.rs.core.Response;
public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator {
@Override
public void action(AuthenticationFlowContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleOTPFormAuthenticator.SINGLETON.action(context);
+ return;
+ }
validateOTP(context);
}
@Override
public void authenticate(AuthenticationFlowContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleOTPFormAuthenticator.SINGLETON.authenticate(context);
+ return;
+ }
Response challengeResponse = challenge(context, null);
context.challenge(challengeResponse);
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java
index bd81263..1dc966f 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java
@@ -19,8 +19,9 @@ package org.keycloak.authentication.authenticators.browser;
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
import org.keycloak.authentication.AuthenticationFlowContext;
-import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.DisplayUtils;
+import org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticator;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -41,6 +42,10 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
@Override
public void action(AuthenticationFlowContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUsernamePasswordAuthenticator.SINGLETON.action(context);
+ return;
+ }
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (formData.containsKey("cancel")) {
context.cancelLogin();
@@ -58,6 +63,10 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
@Override
public void authenticate(AuthenticationFlowContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUsernamePasswordAuthenticator.SINGLETON.authenticate(context);
+ return;
+ }
MultivaluedMap<String, String> formData = new MultivaluedMapImpl<>();
String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleOTPFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleOTPFormAuthenticator.java
new file mode 100755
index 0000000..8fa34e9
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleOTPFormAuthenticator.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.authenticators.console;
+
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.DisplayUtils;
+import org.keycloak.authentication.TextChallenge;
+import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
+import org.keycloak.representations.idm.CredentialRepresentation;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleOTPFormAuthenticator extends OTPFormAuthenticator implements Authenticator {
+ public static final ConsoleOTPFormAuthenticator SINGLETON = new ConsoleOTPFormAuthenticator();
+
+ public static URI getCallbackUrl(AuthenticationFlowContext context) {
+ return context.getActionUrl(context.generateAccessCode(), true);
+ }
+
+ protected TextChallenge challenge(AuthenticationFlowContext context) {
+ return TextChallenge.challenge(context)
+ .header()
+ .param(CredentialRepresentation.TOTP)
+ .label("console-otp")
+ .challenge();
+ }
+
+ @Override
+ public void action(AuthenticationFlowContext context) {
+ validateOTP(context);
+ }
+
+
+
+ @Override
+ public void authenticate(AuthenticationFlowContext context) {
+ Response challengeResponse = challenge(context, null);
+ context.challenge(challengeResponse);
+ }
+
+ @Override
+ protected Response challenge(AuthenticationFlowContext context, String msg) {
+ if (msg == null) {
+ return challenge(context).response();
+ }
+ return challenge(context).message(msg);
+ }
+
+ @Override
+ public void close() {
+
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticator.java
new file mode 100755
index 0000000..4595df5
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticator.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.authenticators.console;
+
+import org.keycloak.authentication.*;
+import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.messages.Messages;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleUsernamePasswordAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator {
+
+ public static final ConsoleUsernamePasswordAuthenticator SINGLETON = new ConsoleUsernamePasswordAuthenticator();
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ protected TextChallenge challenge(AuthenticationFlowContext context) {
+ return TextChallenge.challenge(context)
+ .header()
+ .param("username")
+ .label("console-username")
+ .param("password")
+ .label("console-password")
+ .mask(true)
+ .challenge();
+ }
+
+
+ @Override
+ public void authenticate(AuthenticationFlowContext context) {
+ Response response = challenge(context).form().createForm("cli_splash.ftl");
+ context.challenge(response);
+
+
+ }
+
+ @Override
+ protected Response invalidUser(AuthenticationFlowContext context) {
+ Response response = challenge(context).message(Messages.INVALID_USER);
+ return response;
+ }
+
+ @Override
+ protected Response disabledUser(AuthenticationFlowContext context) {
+ Response response = challenge(context).message(Messages.ACCOUNT_DISABLED);
+ return response;
+ }
+
+ @Override
+ protected Response temporarilyDisabledUser(AuthenticationFlowContext context) {
+ Response response = challenge(context).message(Messages.INVALID_USER);
+ return response;
+ }
+
+ @Override
+ protected Response invalidCredentials(AuthenticationFlowContext context) {
+ Response response = challenge(context).message(Messages.INVALID_USER);
+ return response;
+ }
+
+ @Override
+ protected Response setDuplicateUserChallenge(AuthenticationFlowContext context, String eventError, String loginFormError, AuthenticationFlowError authenticatorError) {
+ context.getEvent().error(eventError);
+ Response response = challenge(context).message(loginFormError);
+
+ context.failureChallenge(authenticatorError, response);
+ return response;
+ }
+
+ @Override
+ public void action(AuthenticationFlowContext context) {
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+ if (!validateUserAndPassword(context, formData)) {
+ return;
+ }
+
+ context.success();
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return true;
+ }
+
+ @Override
+ public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
+ }
+
+ @Override
+ public void close() {
+
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticatorFactory.java
new file mode 100755
index 0000000..05aa235
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleUsernamePasswordAuthenticatorFactory.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.authenticators.console;
+
+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.models.UserCredentialModel;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleUsernamePasswordAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "console-username-password";
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return ConsoleUsernamePasswordAuthenticator.SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return UserCredentialModel.PASSWORD;
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+ public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.REQUIRED
+ };
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Username Password Challenge";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Proprietary challenge protocol for CLI clients that queries for username password";
+ }
+
+ @Override
+ public List<ProviderConfigProperty> getConfigProperties() {
+ return null;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java
index 5e9a546..38b9c2f 100755
--- a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java
+++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java
@@ -33,6 +33,7 @@ import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.sessions.AuthenticationSessionModel;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
@@ -163,6 +164,16 @@ public class RequiredActionContextResult implements RequiredActionContext {
}
@Override
+ public URI getActionUrl(boolean authSessionIdParam) {
+ URI uri = getActionUrl();
+ if (authSessionIdParam) {
+ uri = UriBuilder.fromUri(uri).queryParam(LoginActionsService.AUTH_SESSION_ID, getAuthenticationSession().getParentSession().getId()).build();
+ }
+ return uri;
+
+ }
+
+ @Override
public LoginFormsProvider form() {
String accessCode = generateCode();
URI action = getActionUrl(accessCode);
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleTermsAndConditions.java b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleTermsAndConditions.java
new file mode 100755
index 0000000..41e07d9
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleTermsAndConditions.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.requiredactions;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.RequiredActionContext;
+import org.keycloak.authentication.RequiredActionFactory;
+import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.authentication.TextChallenge;
+import org.keycloak.common.util.Time;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+
+import javax.ws.rs.core.Response;
+import java.util.Arrays;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleTermsAndConditions implements RequiredActionProvider, RequiredActionFactory {
+ public static final ConsoleTermsAndConditions SINGLETON = new ConsoleTermsAndConditions();
+ public static final String PROVIDER_ID = "terms_and_conditions";
+ public static final String USER_ATTRIBUTE = PROVIDER_ID;
+
+ @Override
+ public RequiredActionProvider create(KeycloakSession session) {
+ return this;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+
+ @Override
+ public void evaluateTriggers(RequiredActionContext context) {
+
+ }
+
+
+ @Override
+ public void requiredActionChallenge(RequiredActionContext context) {
+ Response challenge = TextChallenge.challenge(context)
+ .header()
+ .param("accept")
+ .label("console-accept-terms")
+ .message("termsPlainText");
+ context.challenge(challenge);
+ }
+
+ @Override
+ public void processAction(RequiredActionContext context) {
+ String accept = context.getHttpRequest().getDecodedFormParameters().getFirst("accept");
+
+ String yes = context.form().getMessage("console-accept");
+
+ if (!accept.equals(yes)) {
+ context.getUser().removeAttribute(USER_ATTRIBUTE);
+ requiredActionChallenge(context);
+ return;
+ }
+
+ context.getUser().setAttribute(USER_ATTRIBUTE, Arrays.asList(Integer.toString(Time.currentTime())));
+
+ context.success();
+ }
+
+ @Override
+ public String getDisplayText() {
+ return "Terms and Conditions";
+ }
+
+ @Override
+ public void close() {
+
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdatePassword.java
new file mode 100755
index 0000000..5a9aaf9
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdatePassword.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.requiredactions;
+
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.authentication.*;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.models.*;
+import org.keycloak.services.messages.Messages;
+import org.keycloak.services.validation.Validation;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleUpdatePassword extends UpdatePassword implements RequiredActionProvider, RequiredActionFactory {
+ public static final ConsoleUpdatePassword SINGLETON = new ConsoleUpdatePassword();
+
+ private static final Logger logger = Logger.getLogger(ConsoleUpdatePassword.class);
+ public static final String PASSWORD_NEW = "password-new";
+ public static final String PASSWORD_CONFIRM = "password-confirm";
+
+ protected TextChallenge challenge(RequiredActionContext context) {
+ return TextChallenge.challenge(context)
+ .header()
+ .param(PASSWORD_NEW)
+ .label("console-new-password")
+ .mask(true)
+ .param(PASSWORD_CONFIRM)
+ .label("console-confirm-password")
+ .mask(true)
+ .challenge();
+ }
+
+
+
+ @Override
+ public void requiredActionChallenge(RequiredActionContext context) {
+ context.challenge(
+ challenge(context).message("console-update-password"));
+ }
+
+ @Override
+ public void processAction(RequiredActionContext context) {
+ EventBuilder event = context.getEvent();
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+ event.event(EventType.UPDATE_PASSWORD);
+ String passwordNew = formData.getFirst(PASSWORD_NEW);
+ String passwordConfirm = formData.getFirst(PASSWORD_CONFIRM);
+
+ EventBuilder errorEvent = event.clone().event(EventType.UPDATE_PASSWORD_ERROR)
+ .client(context.getAuthenticationSession().getClient())
+ .user(context.getAuthenticationSession().getAuthenticatedUser());
+
+ if (Validation.isBlank(passwordNew)) {
+ context.challenge(challenge(context).message(Messages.MISSING_PASSWORD));
+ errorEvent.error(Errors.PASSWORD_MISSING);
+ return;
+ } else if (!passwordNew.equals(passwordConfirm)) {
+ context.challenge(challenge(context).message(Messages.NOTMATCH_PASSWORD));
+ errorEvent.error(Errors.PASSWORD_CONFIRM_ERROR);
+ return;
+ }
+
+ try {
+ context.getSession().userCredentialManager().updateCredential(context.getRealm(), context.getUser(), UserCredentialModel.password(passwordNew, false));
+ context.success();
+ } catch (ModelException me) {
+ errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED);
+ context.challenge(challenge(context).text(me.getMessage()));
+ return;
+ } catch (Exception ape) {
+ errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED);
+ context.challenge(challenge(context).text(ape.getMessage()));
+ return;
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateProfile.java
new file mode 100644
index 0000000..a5fb335
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateProfile.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.requiredactions;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.RequiredActionContext;
+import org.keycloak.authentication.RequiredActionFactory;
+import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.events.Details;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.FormMessage;
+import org.keycloak.services.messages.Messages;
+import org.keycloak.services.resources.AttributeFormDataProcessor;
+import org.keycloak.services.validation.Validation;
+
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleUpdateProfile implements RequiredActionProvider, RequiredActionFactory {
+ public static final ConsoleUpdateProfile SINGLETON = new ConsoleUpdateProfile();
+
+ @Override
+ public void evaluateTriggers(RequiredActionContext context) {
+ }
+
+ @Override
+ public void requiredActionChallenge(RequiredActionContext context) {
+ // do nothing right now. I think this behavior is ok. We just defer this action until a browser login happens.
+ context.ignore();
+ }
+
+ @Override
+ public void processAction(RequiredActionContext context) {
+ throw new RuntimeException("Should be unreachable");
+
+ }
+
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public RequiredActionProvider create(KeycloakSession session) {
+ return this;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public String getDisplayText() {
+ return "Update Profile";
+ }
+
+
+ @Override
+ public String getId() {
+ return UserModel.RequiredAction.UPDATE_PROFILE.name();
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateTotp.java
new file mode 100644
index 0000000..ec14bfa
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateTotp.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.requiredactions;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.RequiredActionContext;
+import org.keycloak.authentication.RequiredActionFactory;
+import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.authentication.TextChallenge;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.forms.login.LoginFormsProvider;
+import org.keycloak.forms.login.freemarker.model.TotpBean;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.models.UserCredentialModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.CredentialValidation;
+import org.keycloak.services.messages.Messages;
+import org.keycloak.services.validation.Validation;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleUpdateTotp implements RequiredActionProvider, RequiredActionFactory {
+ public static final ConsoleUpdateTotp SINGLETON = new ConsoleUpdateTotp();
+
+ @Override
+ public void evaluateTriggers(RequiredActionContext context) {
+ }
+ @Override
+ public void requiredActionChallenge(RequiredActionContext context) {
+ TotpBean totpBean = new TotpBean(context.getSession(), context.getRealm(), context.getUser(), context.getUriInfo().getRequestUriBuilder());
+ String totpSecret = totpBean.getTotpSecret();
+ context.getAuthenticationSession().setAuthNote("totpSecret", totpSecret);
+ Response challenge = challenge(context).form()
+ .setAttribute("totp", totpBean)
+ .createForm("login-config-totp-text.ftl");
+ context.challenge(challenge);
+ }
+
+ protected TextChallenge challenge(RequiredActionContext context) {
+ return TextChallenge.challenge(context)
+ .header()
+ .param("totp")
+ .label("console-otp")
+ .challenge();
+ }
+
+ @Override
+ public void processAction(RequiredActionContext context) {
+ EventBuilder event = context.getEvent();
+ event.event(EventType.UPDATE_TOTP);
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+ String totp = formData.getFirst("totp");
+ String totpSecret = context.getAuthenticationSession().getAuthNote("totpSecret");
+
+ if (Validation.isBlank(totp)) {
+ context.challenge(
+ challenge(context).message(Messages.MISSING_TOTP)
+ );
+ return;
+ } else if (!CredentialValidation.validOTP(context.getRealm(), totp, totpSecret)) {
+ context.challenge(
+ challenge(context).message(Messages.INVALID_TOTP)
+ );
+ return;
+ }
+
+ UserCredentialModel credentials = new UserCredentialModel();
+ credentials.setType(context.getRealm().getOTPPolicy().getType());
+ credentials.setValue(totpSecret);
+ context.getSession().userCredentialManager().updateCredential(context.getRealm(), context.getUser(), credentials);
+
+
+ // if type is HOTP, to update counter we execute validation based on supplied token
+ UserCredentialModel cred = new UserCredentialModel();
+ cred.setType(context.getRealm().getOTPPolicy().getType());
+ cred.setValue(totp);
+ context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(), cred);
+
+ context.getAuthenticationSession().removeAuthNote("totpSecret");
+ context.success();
+ }
+
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public RequiredActionProvider create(KeycloakSession session) {
+ return this;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public String getDisplayText() {
+ return "Configure OTP";
+ }
+
+
+ @Override
+ public String getId() {
+ return UserModel.RequiredAction.CONFIGURE_TOTP.name();
+ }
+
+ @Override
+ public boolean isOneTimeAction() {
+ return true;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleVerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleVerifyEmail.java
new file mode 100755
index 0000000..8eddf37
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleVerifyEmail.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.authentication.requiredactions;
+
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.authentication.RequiredActionContext;
+import org.keycloak.authentication.RequiredActionFactory;
+import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.authentication.TextChallenge;
+import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
+import org.keycloak.common.util.RandomString;
+import org.keycloak.common.util.Time;
+import org.keycloak.email.EmailException;
+import org.keycloak.email.EmailTemplateProvider;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.forms.login.LoginFormsProvider;
+import org.keycloak.models.*;
+import org.keycloak.services.Urls;
+import org.keycloak.services.messages.Messages;
+import org.keycloak.services.validation.Validation;
+import org.keycloak.sessions.AuthenticationSessionCompoundId;
+import org.keycloak.sessions.AuthenticationSessionModel;
+
+import javax.ws.rs.core.*;
+import java.net.URI;
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ConsoleVerifyEmail implements RequiredActionProvider, RequiredActionFactory {
+ public static final ConsoleVerifyEmail SINGLETON = new ConsoleVerifyEmail();
+ private static final Logger logger = Logger.getLogger(ConsoleVerifyEmail.class);
+ @Override
+ public void evaluateTriggers(RequiredActionContext context) {
+ if (context.getRealm().isVerifyEmail() && !context.getUser().isEmailVerified()) {
+ context.getUser().addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL);
+ logger.debug("User is required to verify email");
+ }
+ }
+
+ @Override
+ public void requiredActionChallenge(RequiredActionContext context) {
+ AuthenticationSessionModel authSession = context.getAuthenticationSession();
+
+ if (context.getUser().isEmailVerified()) {
+ context.success();
+ authSession.removeAuthNote(Constants.VERIFY_EMAIL_KEY);
+ return;
+ }
+
+ String email = context.getUser().getEmail();
+ if (Validation.isBlank(email)) {
+ context.ignore();
+ return;
+ }
+
+ Response challenge = sendVerifyEmail(context);
+ context.challenge(challenge);
+ }
+
+
+ @Override
+ public void processAction(RequiredActionContext context) {
+ EventBuilder event = context.getEvent().clone().event(EventType.VERIFY_EMAIL).detail(Details.EMAIL, context.getUser().getEmail());
+ String code = context.getAuthenticationSession().getAuthNote(Constants.VERIFY_EMAIL_CODE);
+ if (code == null) {
+ requiredActionChallenge(context);
+ return;
+ }
+
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+ String emailCode = formData.getFirst(EMAIL_CODE);
+
+ if (!code.equals(emailCode)) {
+ context.challenge(
+ challenge(context).message(Messages.INVALID_CODE)
+ );
+ event.error(Errors.INVALID_CODE);
+ return;
+ }
+ event.success();
+ context.success();
+ }
+
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public RequiredActionProvider create(KeycloakSession session) {
+ return this;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public String getDisplayText() {
+ return "Verify Email";
+ }
+
+
+ public static String EMAIL_CODE="email_code";
+ @Override
+ public String getId() {
+ return UserModel.RequiredAction.VERIFY_EMAIL.name();
+ }
+
+ protected TextChallenge challenge(RequiredActionContext context) {
+ return TextChallenge.challenge(context)
+ .header()
+ .param(EMAIL_CODE)
+ .label("console-email-code")
+ .challenge();
+ }
+
+ private Response sendVerifyEmail(RequiredActionContext context) throws UriBuilderException, IllegalArgumentException {
+ KeycloakSession session = context.getSession();
+ UserModel user = context.getUser();
+ AuthenticationSessionModel authSession = context.getAuthenticationSession();
+ EventBuilder event = context.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, user.getEmail());
+ String code = RandomString.randomCode(8);
+ authSession.setAuthNote(Constants.VERIFY_EMAIL_CODE, code);
+ RealmModel realm = session.getContext().getRealm();
+
+ Map<String, Object> attributes = new HashMap<>();
+ attributes.put("code", code);
+
+ try {
+ session
+ .getProvider(EmailTemplateProvider.class)
+ .setAuthenticationSession(authSession)
+ .setRealm(realm)
+ .setUser(user)
+ .send("emailVerificationSubject", "email-verification-with-code.ftl", attributes);
+ event.success();
+ } catch (EmailException e) {
+ logger.error("Failed to send verification email", e);
+ event.error(Errors.EMAIL_SEND_FAILED);
+ }
+
+ return challenge(context).text(context.form().getMessage("console-verify-email", user.getEmail()));
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java b/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java
index f4a1566..961095c 100755
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java
@@ -18,6 +18,7 @@
package org.keycloak.authentication.requiredactions;
import org.keycloak.Config;
+import org.keycloak.authentication.DisplayUtils;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
@@ -65,12 +66,20 @@ public class TermsAndConditions implements RequiredActionProvider, RequiredActio
@Override
public void requiredActionChallenge(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleTermsAndConditions.SINGLETON.requiredActionChallenge(context);
+ return;
+ }
Response challenge = context.form().createForm("terms.ftl");
context.challenge(challenge);
}
@Override
public void processAction(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleTermsAndConditions.SINGLETON.processAction(context);
+ return;
+ }
if (context.getHttpRequest().getDecodedFormParameters().containsKey("cancel")) {
context.getUser().removeAttribute(USER_ATTRIBUTE);
context.failure();
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java
index 1e9f37a..65ddc9e 100755
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java
@@ -22,6 +22,7 @@ import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.authentication.DisplayUtils;
import org.keycloak.common.util.Time;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
@@ -74,6 +75,10 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
@Override
public void requiredActionChallenge(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdatePassword.SINGLETON.requiredActionChallenge(context);
+ return;
+ }
Response challenge = context.form()
.setAttribute("username", context.getAuthenticationSession().getAuthenticatedUser().getUsername())
.createResponse(UserModel.RequiredAction.UPDATE_PASSWORD);
@@ -82,6 +87,10 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
@Override
public void processAction(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdatePassword.SINGLETON.processAction(context);
+ return;
+ }
EventBuilder event = context.getEvent();
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
event.event(EventType.UPDATE_PASSWORD);
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
index 3ed1f12..f790626 100644
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
@@ -18,6 +18,7 @@
package org.keycloak.authentication.requiredactions;
import org.keycloak.Config;
+import org.keycloak.authentication.DisplayUtils;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
@@ -48,6 +49,10 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
@Override
public void requiredActionChallenge(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdateProfile.SINGLETON.requiredActionChallenge(context);
+ return;
+ }
Response challenge = context.form()
.createResponse(UserModel.RequiredAction.UPDATE_PROFILE);
context.challenge(challenge);
@@ -55,6 +60,10 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
@Override
public void processAction(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdateProfile.SINGLETON.processAction(context);
+ return;
+ }
EventBuilder event = context.getEvent();
event.event(EventType.UPDATE_PROFILE);
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java
index e85ec7e..e715933 100644
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java
@@ -21,6 +21,7 @@ import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
+import org.keycloak.authentication.DisplayUtils;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSession;
@@ -45,6 +46,10 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
@Override
public void requiredActionChallenge(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdateTotp.SINGLETON.requiredActionChallenge(context);
+ return;
+ }
Response challenge = context.form()
.setAttribute("mode", getMode(context))
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
@@ -57,6 +62,10 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
@Override
public void processAction(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleUpdateTotp.SINGLETON.processAction(context);
+ return;
+ }
EventBuilder event = context.getEvent();
event.event(EventType.UPDATE_TOTP);
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
index 969f350..9333905 100755
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
@@ -23,6 +23,7 @@ import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
+import org.keycloak.authentication.DisplayUtils;
import org.keycloak.common.util.Time;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
@@ -56,6 +57,10 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
}
@Override
public void requiredActionChallenge(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleVerifyEmail.SINGLETON.requiredActionChallenge(context);
+ return;
+ }
AuthenticationSessionModel authSession = context.getAuthenticationSession();
if (context.getUser().isEmailVerified()) {
@@ -88,6 +93,10 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
@Override
public void processAction(RequiredActionContext context) {
+ if (DisplayUtils.isConsole(context)) {
+ ConsoleVerifyEmail.SINGLETON.processAction(context);
+ return;
+ }
logger.debugf("Re-sending email requested for user: %s", context.getUser().getUsername());
// This will allow user to re-send email again
diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java
index c55a585..e3e447f 100755
--- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java
+++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java
@@ -192,8 +192,9 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
}
}
- protected void send(String subjectKey, String template, Map<String, Object> attributes) throws EmailException {
- send(subjectKey, Collections.emptyList(), template, attributes);
+ @Override
+ public void send(String subjectFormatKey, String bodyTemplate, Map<String, Object> bodyAttributes) throws EmailException {
+ send(subjectFormatKey, Collections.emptyList(), bodyTemplate, bodyAttributes);
}
protected EmailTemplate processTemplate(String subjectKey, List<Object> subjectAttributes, String template, Map<String, Object> attributes) throws EmailException {
@@ -229,9 +230,10 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
return session.theme().getTheme(Theme.Type.EMAIL);
}
- protected void send(String subjectKey, List<Object> subjectAttributes, String template, Map<String, Object> attributes) throws EmailException {
+ @Override
+ public void send(String subjectFormatKey, List<Object> subjectAttributes, String bodyTemplate, Map<String, Object> bodyAttributes) throws EmailException {
try {
- EmailTemplate email = processTemplate(subjectKey, subjectAttributes, template, attributes);
+ EmailTemplate email = processTemplate(subjectFormatKey, subjectAttributes, bodyTemplate, bodyAttributes);
send(email.getSubject(), email.getTextBody(), email.getHtmlBody());
} catch (EmailException e) {
throw e;
diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
index cf480fc..3d0f52d 100755
--- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
+++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
@@ -332,9 +332,25 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
Properties messagesBundle = handleThemeResources(theme, locale);
FormMessage msg = new FormMessage(null, message);
return formatMessage(msg, messagesBundle, locale);
+ }
+ @Override
+ public String getMessage(String message, String... parameters) {
+ Theme theme;
+ try {
+ theme = getTheme();
+ } catch (IOException e) {
+ logger.error("Failed to create theme", e);
+ throw new RuntimeException("Failed to create theme");
+ }
+
+ Locale locale = session.getContext().resolveLocale(user);
+ Properties messagesBundle = handleThemeResources(theme, locale);
+ FormMessage msg = new FormMessage(message, parameters);
+ return formatMessage(msg, messagesBundle, locale);
}
-
+
+
/**
* Create common attributes used in all templates.
*
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
index cf0bb4a..65c66e2 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
@@ -371,6 +371,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
if (request.getResponseMode() != null) authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, request.getResponseMode());
if (request.getClaims()!= null) authenticationSession.setClientNote(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
if (request.getAcr() != null) authenticationSession.setClientNote(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
+ if (request.getDisplay() != null) authenticationSession.setClientNote(OAuth2Constants.DISPLAY, request.getDisplay());
// https://tools.ietf.org/html/rfc7636#section-4
if (request.getCodeChallenge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM, request.getCodeChallenge());
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
index 29edb03..083c2c3 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
@@ -32,6 +32,7 @@ public class AuthorizationEndpointRequest {
String state;
String scope;
String loginHint;
+ String display;
String prompt;
String nonce;
Integer maxAge;
@@ -111,4 +112,7 @@ public class AuthorizationEndpointRequest {
return codeChallengeMethod;
}
+ public String getDisplay() {
+ return display;
+ }
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
index 90160ee..d6cb1b7 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
@@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc.endpoints.request;
import org.jboss.logging.Logger;
+import org.keycloak.OAuth2Constants;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@@ -91,6 +92,7 @@ abstract class AuthzEndpointRequestParser {
request.maxAge = replaceIfNotNull(request.maxAge, getIntParameter(OIDCLoginProtocol.MAX_AGE_PARAM));
request.claims = replaceIfNotNull(request.claims, getParameter(OIDCLoginProtocol.CLAIMS_PARAM));
request.acr = replaceIfNotNull(request.acr, getParameter(OIDCLoginProtocol.ACR_PARAM));
+ request.display = replaceIfNotNull(request.display, getParameter(OAuth2Constants.DISPLAY));
// https://tools.ietf.org/html/rfc7636#section-6.1
request.codeChallenge = replaceIfNotNull(request.codeChallenge, getParameter(OIDCLoginProtocol.CODE_CHALLENGE_PARAM));
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 05f9f4c..38d2489 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -776,8 +776,15 @@ public class TokenEndpoint {
String audience = formParams.getFirst(OAuth2Constants.AUDIENCE);
if (audience != null) {
targetClient = realm.getClientByClientId(audience);
+ if (targetClient == null) {
+ event.detail(Details.REASON, "audience not found");
+ event.error(Errors.CLIENT_NOT_FOUND);
+ throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Audience not found", Response.Status.BAD_REQUEST);
+
+ }
}
+
if (targetClient.isConsentRequired()) {
event.detail(Details.REASON, "audience requires consent");
event.error(Errors.CONSENT_DENIED);
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
index 36d93fa..4da336c 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
@@ -71,6 +71,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String MAX_AGE_PARAM = OAuth2Constants.MAX_AGE;
public static final String PROMPT_PARAM = OAuth2Constants.PROMPT;
public static final String LOGIN_HINT_PARAM = "login_hint";
+ public static final String DISPLAY_PARAM = "display";
public static final String REQUEST_PARAM = "request";
public static final String REQUEST_URI_PARAM = "request_uri";
public static final String UI_LOCALES_PARAM = OAuth2Constants.UI_LOCALES_PARAM;
diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
index 803e778..a426bc2 100755
--- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
@@ -761,6 +761,11 @@ public class AuthenticationManager {
uriBuilder.queryParam(Constants.CLIENT_ID, authSession.getClient().getClientId());
uriBuilder.queryParam(Constants.TAB_ID, authSession.getTabId());
+ if (uriInfo.getQueryParameters().containsKey(LoginActionsService.AUTH_SESSION_ID)) {
+ uriBuilder.queryParam(LoginActionsService.AUTH_SESSION_ID, authSession.getParentSession().getId());
+
+ }
+
URI redirect = uriBuilder.build(realm.getName());
return Response.status(302).location(redirect).build();
diff --git a/services/src/main/java/org/keycloak/utils/TotpUtils.java b/services/src/main/java/org/keycloak/utils/TotpUtils.java
index 67ff697..d076735 100644
--- a/services/src/main/java/org/keycloak/utils/TotpUtils.java
+++ b/services/src/main/java/org/keycloak/utils/TotpUtils.java
@@ -45,6 +45,12 @@ public class TotpUtils {
return sb.toString();
}
+ public static String decode(String totpSecretEncoded) {
+ String encoded = totpSecretEncoded.replace(" ", "");
+ byte[] bytes = Base32.decode(encoded);
+ return new String(bytes);
+ }
+
public static String qrCode(String totpSecret, RealmModel realm, UserModel user) {
try {
String keyUri = realm.getOTPPolicy().getKeyURI(realm, user, totpSecret);
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
index 76b7507..ee29448 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
@@ -38,4 +38,4 @@ org.keycloak.protocol.saml.profile.ecp.authenticator.HttpBasicAuthenticatorFacto
org.keycloak.authentication.authenticators.x509.X509ClientCertificateAuthenticatorFactory
org.keycloak.authentication.authenticators.x509.ValidateX509CertificateUsernameFactory
org.keycloak.protocol.docker.DockerAuthenticatorFactory
-org.keycloak.authentication.authenticators.cli.CliUsernamePasswordAuthenticatorFactory
+org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticatorFactory
diff --git a/testsuite/integration-arquillian/tests/base/pom.xml b/testsuite/integration-arquillian/tests/base/pom.xml
index 49babb7..404fc82 100644
--- a/testsuite/integration-arquillian/tests/base/pom.xml
+++ b/testsuite/integration-arquillian/tests/base/pom.xml
@@ -228,6 +228,13 @@
<type>zip</type>
<outputDirectory>${containers.home}</outputDirectory>
</artifactItem>
+ <artifactItem>
+ <groupId>org.keycloak</groupId>
+ <artifactId>kcinit-dist</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ <outputDirectory>${containers.home}</outputDirectory>
+ </artifactItem>
</artifactItems>
</configuration>
</execution>
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java
index b5476b5..408dc35 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java
@@ -1,5 +1,6 @@
package org.keycloak.testsuite.cli.exec;
+import org.keycloak.client.admin.cli.util.OsUtil;
import org.keycloak.testsuite.cli.OsArch;
import org.keycloak.testsuite.cli.OsUtils;
@@ -177,6 +178,7 @@ public abstract class AbstractExec {
return new String(stdout.toByteArray());
}
+
public InputStream stderr() {
return new ByteArrayInputStream(stderr.toByteArray());
}
@@ -224,6 +226,22 @@ public abstract class AbstractExec {
throw new RuntimeException("Timed while waiting for content to appear in stdout");
}
+ public void waitForStderr(String content) {
+ long start = System.currentTimeMillis();
+ while (System.currentTimeMillis() - start < waitTimeout) {
+ if (stderrString().indexOf(content) != -1) {
+ return;
+ }
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ throw new RuntimeException("Interrupted ...", e);
+ }
+ }
+
+ throw new RuntimeException("Timed while waiting for content to appear in stdout");
+ }
+
public void sendToStdin(String s) {
if (stdin instanceof InteractiveInputStream) {
((InteractiveInputStream) stdin).pushBytes(s.getBytes());
@@ -232,6 +250,10 @@ public abstract class AbstractExec {
}
}
+ public void sendLine(String s) {
+ sendToStdin(s + OsUtil.EOL);
+ }
+
static void copyStream(InputStream is, OutputStream os) throws IOException {
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcinitExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcinitExec.java
new file mode 100644
index 0000000..99ae7ef
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcinitExec.java
@@ -0,0 +1,58 @@
+package org.keycloak.testsuite.cli;
+
+import org.keycloak.testsuite.cli.exec.AbstractExec;
+import org.keycloak.testsuite.cli.exec.AbstractExecBuilder;
+
+import java.io.InputStream;
+
+/**
+ * @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a>
+ */
+public class KcinitExec extends AbstractExec {
+
+ public static final String WORK_DIR = System.getProperty("user.dir") + "/target/containers/kcinit";
+
+ public static final String CMD = OS_ARCH.isWindows() ? "kcinit.bat" : "kcinit";
+
+ private KcinitExec(String workDir, String argsLine, InputStream stdin) {
+ this(workDir, argsLine, null, stdin);
+ }
+
+ private KcinitExec(String workDir, String argsLine, String env, InputStream stdin) {
+ super(workDir, argsLine, env, stdin);
+ }
+
+ @Override
+ public String getCmd() {
+ return "./" + CMD;
+ }
+
+ public static KcinitExec.Builder newBuilder() {
+ return (KcinitExec.Builder) new KcinitExec.Builder().workDir(WORK_DIR);
+ }
+
+ public static KcinitExec execute(String args) {
+ return newBuilder()
+ .argsLine(args)
+ .execute();
+ }
+
+ public static class Builder extends AbstractExecBuilder<KcinitExec> {
+
+ @Override
+ public KcinitExec execute() {
+ KcinitExec exe = new KcinitExec(workDir, argsLine, env, stdin);
+ exe.dumpStreams = dumpStreams;
+ exe.execute();
+ return exe;
+ }
+
+ @Override
+ public KcinitExec executeAsync() {
+ KcinitExec exe = new KcinitExec(workDir, argsLine, env, stdin);
+ exe.dumpStreams = dumpStreams;
+ exe.executeAsync();
+ return exe;
+ }
+ }
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/KcinitTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/KcinitTest.java
new file mode 100644
index 0000000..e54aee7
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/KcinitTest.java
@@ -0,0 +1,499 @@
+/*
+ * Copyright 2017 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.cli;
+
+import org.jboss.arquillian.container.test.api.Deployment;
+import org.jboss.arquillian.graphene.page.Page;
+import org.jboss.shrinkwrap.api.spec.WebArchive;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticatorFactory;
+import org.keycloak.authentication.requiredactions.TermsAndConditions;
+import org.keycloak.authorization.model.Policy;
+import org.keycloak.authorization.model.ResourceServer;
+import org.keycloak.credential.CredentialModel;
+import org.keycloak.models.*;
+import org.keycloak.models.utils.TimeBasedOTP;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
+import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
+import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
+import org.keycloak.services.resources.admin.permissions.AdminPermissions;
+import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.pages.AppPage;
+import org.keycloak.testsuite.pages.ErrorPage;
+import org.keycloak.testsuite.pages.LoginPage;
+import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
+import org.keycloak.testsuite.util.GreenMailRule;
+import org.keycloak.testsuite.util.MailUtils;
+import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.util.JsonSerialization;
+import org.keycloak.utils.TotpUtils;
+
+import javax.mail.internet.MimeMessage;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Test that clients can override auth flows
+ *
+ * @author <a href="mailto:bburke@redhat.com">Bill Burke</a>
+ */
+public class KcinitTest extends AbstractTestRealmKeycloakTest {
+
+ public static final String KCINIT_CLIENT = "kcinit";
+ public static final String APP = "app";
+ public static final String UNAUTHORIZED_APP = "unauthorized_app";
+ @Rule
+ public AssertEvents events = new AssertEvents(this);
+
+ @Override
+ public void configureTestRealm(RealmRepresentation testRealm) {
+ }
+
+ @Deployment
+ public static WebArchive deploy() {
+ return RunOnServerDeployment.create(UserResource.class)
+ .addPackages(true, "org.keycloak.testsuite");
+ }
+
+
+ @Before
+ public void setupFlows() {
+ RequiredActionProviderRepresentation rep = adminClient.realm("test").flows().getRequiredAction("terms_and_conditions");
+ rep.setEnabled(true);
+ adminClient.realm("test").flows().updateRequiredAction("terms_and_conditions", rep);
+
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+
+ ClientModel client = session.realms().getClientByClientId("kcinit", realm);
+ if (client != null) {
+ return;
+ }
+
+ ClientModel kcinit = realm.addClient(KCINIT_CLIENT);
+ kcinit.setSecret("password");
+ kcinit.setEnabled(true);
+ kcinit.addRedirectUri("urn:ietf:wg:oauth:2.0:oob");
+ kcinit.setPublicClient(false);
+
+ ClientModel app = realm.addClient(APP);
+ app.setSecret("password");
+ app.setEnabled(true);
+ app.setPublicClient(false);
+
+ ClientModel unauthorizedApp = realm.addClient(UNAUTHORIZED_APP);
+ unauthorizedApp.setSecret("password");
+ unauthorizedApp.setEnabled(true);
+ unauthorizedApp.setPublicClient(false);
+
+ // permission for client to client exchange to "target" client
+ AdminPermissionManagement management = AdminPermissions.management(session, realm);
+ management.clients().setPermissionsEnabled(app, true);
+ ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation();
+ clientRep.setName("to");
+ clientRep.addClient(kcinit.getId());
+ ResourceServer server = management.realmResourceServer();
+ Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(clientRep, server);
+ management.clients().exchangeToPermission(app).addAssociatedPolicy(clientPolicy);
+ PasswordPolicy policy = realm.getPasswordPolicy();
+ policy = PasswordPolicy.parse(session, "hashIterations(1)");
+ realm.setPasswordPolicy(policy);
+
+ UserModel user = session.users().addUser(realm, "bburke");
+ session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password("password"));
+ user.setEnabled(true);
+ user.setEmail("patriot1burke@gmail.com");
+ user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
+ user.addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
+ user.addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL);
+ user.addRequiredAction(TermsAndConditions.PROVIDER_ID);
+ user.addRequiredAction(UserModel.RequiredAction.UPDATE_PROFILE);
+
+ user = session.users().addUser(realm, "wburke");
+ session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password("password"));
+ user.setEnabled(true);
+ });
+ }
+
+ //@Test
+ public void testDemo() throws Exception {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ Map<String, String> smtp = new HashMap<>();
+ smtp.put("host", "smtp.gmail.com");
+ smtp.put("port", "465");
+ smtp.put("fromDisplayName", "Keycloak SSO");
+ smtp.put("from", "****");
+ smtp.put("replyToDisplayName", "Keycloak no-reply");
+ smtp.put("replyTo", "reply-to@keycloak.org");
+ smtp.put("ssl", "true");
+ smtp.put("auth", "true");
+ smtp.put("user", "****");
+ smtp.put("password", "****");
+ realm.setSmtpConfig(smtp);
+
+ });
+
+ Thread.sleep(10000000);
+ }
+
+ @Test
+ public void testBadCommand() throws Exception {
+ KcinitExec exe = KcinitExec.execute("covfefe");
+ Assert.assertEquals(1, exe.exitCode());
+ Assert.assertEquals("stderr first line", "Unknown command: covfefe", exe.stderrLines().get(0));
+ }
+
+ //@Test
+ public void testInstall() throws Exception {
+ KcinitExec exe = KcinitExec.execute("uninstall");
+ Assert.assertEquals(0, exe.exitCode());
+
+ exe = KcinitExec.newBuilder()
+ .argsLine("install")
+ .executeAsync();
+ exe.waitForStderr("(y/n):");
+ exe.sendLine("n");
+ exe.waitForStderr("Authentication server URL [http://localhost:8080/auth]:");
+ exe.sendLine(OAuthClient.AUTH_SERVER_ROOT);
+ exe.waitForStderr("Name of realm [master]:");
+ exe.sendLine("test");
+ exe.waitForStderr("client id [kcinit]:");
+ exe.sendLine("");
+ exe.waitForStderr("client secret [none]:");
+ exe.sendLine("password");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ }
+
+ @Test
+ public void testBasic() throws Exception {
+ testInstall();
+ // login
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+
+ exe = KcinitExec.execute("token");
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(1, exe.stdoutLines().size());
+ String token = exe.stdoutLines().get(0).trim();
+ //System.out.println("token: " + token);
+ String introspect = oauth.introspectAccessTokenWithClientCredential("kcinit", "password", token);
+ Map json = JsonSerialization.readValue(introspect, Map.class);
+ Assert.assertTrue(json.containsKey("active"));
+ Assert.assertTrue((Boolean)json.get("active"));
+ //System.out.println("introspect");
+ //System.out.println(introspect);
+
+ exe = KcinitExec.execute("token app");
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(1, exe.stdoutLines().size());
+ String appToken = exe.stdoutLines().get(0).trim();
+ Assert.assertFalse(appToken.equals(token));
+ //System.out.println("token: " + token);
+ introspect = oauth.introspectAccessTokenWithClientCredential("kcinit", "password", appToken);
+ json = JsonSerialization.readValue(introspect, Map.class);
+ Assert.assertTrue(json.containsKey("active"));
+ Assert.assertTrue((Boolean)json.get("active"));
+
+
+ exe = KcinitExec.execute("token badapp");
+ Assert.assertEquals(1, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+ Assert.assertEquals(1, exe.stderrLines().size());
+ Assert.assertTrue(exe.stderrLines().get(0).contains("Failed to exchange token: invalid_client. Audience not found"));
+
+ exe = KcinitExec.execute("logout");
+ Assert.assertEquals(0, exe.exitCode());
+
+ introspect = oauth.introspectAccessTokenWithClientCredential("kcinit", "password", token);
+ json = JsonSerialization.readValue(introspect, Map.class);
+ Assert.assertTrue(json.containsKey("active"));
+ Assert.assertFalse((Boolean)json.get("active"));
+
+
+
+ }
+
+ @Test
+ public void testTerms() throws Exception {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ user.addRequiredAction(TermsAndConditions.PROVIDER_ID);
+ });
+
+ testInstall();
+
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("Accept Terms? [y/n]:");
+ exe.sendLine("y");
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+ }
+
+
+ @Test
+ public void testUpdateProfile() throws Exception {
+ // expects that updateProfile is a passthrough
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ user.addRequiredAction(UserModel.RequiredAction.UPDATE_PROFILE);
+ });
+
+ try {
+ testInstall();
+
+ //Thread.sleep(100000000);
+
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ try {
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+ } catch (Exception ex) {
+ System.out.println(exe.stderrString());
+ throw ex;
+ }
+ } finally {
+
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ user.removeRequiredAction(UserModel.RequiredAction.UPDATE_PROFILE);
+ });
+ }
+ }
+
+
+ @Test
+ public void testUpdatePassword() throws Exception {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
+ });
+
+ try {
+ testInstall();
+
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("New Password:");
+ exe.sendLine("pw");
+ exe.waitForStderr("Confirm Password:");
+ exe.sendLine("pw");
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+
+ exe = KcinitExec.newBuilder()
+ .argsLine("-f")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("pw");
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+
+ exe = KcinitExec.execute("logout");
+ Assert.assertEquals(0, exe.exitCode());
+ } finally {
+
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password("password"));
+ });
+ }
+
+ }
+
+ protected TimeBasedOTP totp = new TimeBasedOTP();
+
+
+ @Test
+ public void testConfigureTOTP() throws Exception {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ user.addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
+ });
+
+ try {
+
+ testInstall();
+
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("One Time Password:");
+
+ Pattern p = Pattern.compile("Open the application and enter the key\\s+(.+)\\s+Use the following configuration values");
+ //Pattern p = Pattern.compile("Open the application and enter the key");
+
+ String stderr = exe.stderrString();
+ //System.out.println("***************");
+ //System.out.println(stderr);
+ //System.out.println("***************");
+ Matcher m = p.matcher(stderr);
+ Assert.assertTrue(m.find());
+ String otpSecret = m.group(1).trim();
+
+ //System.out.println("***************");
+ //System.out.println(otpSecret);
+ //System.out.println("***************");
+
+ otpSecret = TotpUtils.decode(otpSecret);
+ String code = totp.generateTOTP(otpSecret);
+ //System.out.println("***************");
+ //System.out.println("code: " + code);
+ //System.out.println("***************");
+ exe.sendLine(code);
+ Thread.sleep(100);
+ //stderr = exe.stderrString();
+ //System.out.println("***************");
+ //System.out.println(stderr);
+ //System.out.println("***************");
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+
+
+ exe = KcinitExec.execute("logout");
+ Assert.assertEquals(0, exe.exitCode());
+
+ exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("wburke");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("One Time Password:");
+ exe.sendLine(totp.generateTOTP(otpSecret));
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+
+ exe = KcinitExec.execute("logout");
+ Assert.assertEquals(0, exe.exitCode());
+ } finally {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("wburke", realm);
+ session.userCredentialManager().disableCredentialType(realm, user, CredentialModel.OTP);
+ });
+ }
+
+
+ }
+
+ @Rule
+ public GreenMailRule greenMail = new GreenMailRule();
+
+ @Test
+ public void testVerifyEmail() throws Exception {
+ testingClient.server().run(session -> {
+ RealmModel realm = session.realms().getRealmByName("test");
+ UserModel user = session.users().getUserByUsername("test-user@localhost", realm);
+ user.addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL);
+ });
+
+ testInstall();
+
+ KcinitExec exe = KcinitExec.newBuilder()
+ .argsLine("")
+ .executeAsync();
+ exe.waitForStderr("Username:");
+ exe.sendLine("test-user@localhost");
+ exe.waitForStderr("Password:");
+ exe.sendLine("password");
+ exe.waitForStderr("Email Code:");
+
+ Assert.assertEquals(1, greenMail.getReceivedMessages().length);
+
+ MimeMessage message = greenMail.getReceivedMessages()[0];
+
+ String text = MailUtils.getBody(message).getText();
+ Assert.assertTrue(text.contains("Please verify your email address by entering in the following code."));
+ String code = text.substring("Please verify your email address by entering in the following code.".length()).trim();
+
+ exe.sendLine(code);
+
+ exe.waitForStderr("Login successful");
+ exe.waitCompletion();
+ Assert.assertEquals(0, exe.exitCode());
+ Assert.assertEquals(0, exe.stdoutLines().size());
+
+
+ exe = KcinitExec.execute("logout");
+ Assert.assertEquals(0, exe.exitCode());
+ }
+
+
+
+
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ChallengeFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ChallengeFlowTest.java
index 72024f6..5855979 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ChallengeFlowTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ChallengeFlowTest.java
@@ -24,29 +24,21 @@ import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
-import org.keycloak.OAuth2Constants;
-import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.UserResource;
-import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
-import org.keycloak.authentication.authenticators.cli.CliUsernamePasswordAuthenticatorFactory;
-import org.keycloak.events.Details;
+import org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowBindings;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
-import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
-import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
import org.keycloak.testsuite.util.OAuthClient;
-import org.keycloak.util.BasicAuthHelper;
-import org.openqa.selenium.By;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
@@ -55,9 +47,6 @@ import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
-import javax.ws.rs.core.UriBuilder;
-import java.net.URI;
-import java.net.URL;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
@@ -120,7 +109,7 @@ public class ChallengeFlowTest extends AbstractTestRealmKeycloakTest {
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(browser.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
- execution.setAuthenticator(CliUsernamePasswordAuthenticatorFactory.PROVIDER_ID);
+ execution.setAuthenticator(ConsoleUsernamePasswordAuthenticatorFactory.PROVIDER_ID);
execution.setPriority(10);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
diff --git a/themes/src/main/resources/theme/base/email/html/email-verification-with-code.ftl b/themes/src/main/resources/theme/base/email/html/email-verification-with-code.ftl
new file mode 100644
index 0000000..b4a01c9
--- /dev/null
+++ b/themes/src/main/resources/theme/base/email/html/email-verification-with-code.ftl
@@ -0,0 +1,5 @@
+<html>
+<body>
+${msg("emailVerificationBodyCodeHtml",code)?no_esc}
+</body>
+</html>
diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties
index e04e947..b2fd0c0 100755
--- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties
@@ -45,3 +45,7 @@ linkExpirationFormatter.timePeriodUnit.hours=hours
linkExpirationFormatter.timePeriodUnit.hours.1=hour
linkExpirationFormatter.timePeriodUnit.days=days
linkExpirationFormatter.timePeriodUnit.days.1=day
+
+emailVerificationBodyCode=Please verify your email address by entering in the following code.\n\n{0}\n\n.
+emailVerificationBodyCodeHtml=<p>Please verify your email address by entering in the following code.</p><p><b>{0}</b></p>
+
diff --git a/themes/src/main/resources/theme/base/email/text/email-verification-with-code.ftl b/themes/src/main/resources/theme/base/email/text/email-verification-with-code.ftl
new file mode 100644
index 0000000..4ffb7d8
--- /dev/null
+++ b/themes/src/main/resources/theme/base/email/text/email-verification-with-code.ftl
@@ -0,0 +1,2 @@
+<#ftl output_format="plainText">
+${msg("emailVerificationBodyCode",code)}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/login/login-config-totp-text.ftl b/themes/src/main/resources/theme/base/login/login-config-totp-text.ftl
new file mode 100755
index 0000000..22360f3
--- /dev/null
+++ b/themes/src/main/resources/theme/base/login/login-config-totp-text.ftl
@@ -0,0 +1,27 @@
+<#ftl output_format="plainText">
+${msg("loginTotpIntro")}
+
+${msg("loginTotpStep1")}
+
+<#list totp.policy.supportedApplications as app>
+* ${app}
+</#list>
+
+${msg("loginTotpManualStep2")}
+
+ ${totp.totpSecretEncoded}
+
+
+${msg("loginTotpManualStep3")}
+
+- ${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}
+- ${msg("loginTotpAlgorithm")}: ${totp.policy.getAlgorithmKey()}
+- ${msg("loginTotpDigits")}: ${totp.policy.digits}
+<#if totp.policy.type = "totp">
+- ${msg("loginTotpInterval")}: ${totp.policy.period}
+
+<#elseif totp.policy.type = "hotp">
+- ${msg("loginTotpCounter")}: ${totp.policy.initialCounter}
+
+</#if>
+
diff --git a/themes/src/main/resources/theme/base/login/login-verify-email-code-text.ftl b/themes/src/main/resources/theme/base/login/login-verify-email-code-text.ftl
new file mode 100644
index 0000000..87abcd7
--- /dev/null
+++ b/themes/src/main/resources/theme/base/login/login-verify-email-code-text.ftl
@@ -0,0 +1,2 @@
+<#ftl output_format="plainText">
+${msg("console-verify-email",email, code)}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties
index 3e61043..f53e7c4 100755
--- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties
@@ -39,6 +39,7 @@ codeErrorTitle=Error code\: {0}
termsTitle=Terms and Conditions
termsTitleHtml=Terms and Conditions
termsText=<p>Terms and conditions to be defined</p>
+termsPlainText=Terms and conditions to be defined.
recaptchaFailed=Invalid Recaptcha
recaptchaNotConfigured=Recaptcha is required, but not configured
@@ -68,6 +69,7 @@ country=Country
emailVerified=Email verified
gssDelegationCredential=GSS Delegation Credential
+loginTotpIntro=You are required to set up a One Time Password generator to access this account
loginTotpStep1=Install one of the following applications on your mobile
loginTotpStep2=Open the application and scan the barcode
loginTotpStep3=Enter the one-time code provided by the application and click Submit to finish the setup
@@ -280,3 +282,14 @@ noCertificate=[No Certificate]
pageNotFound=Page not found
internalServerError=An internal server error has occurred
+
+console-username=Username:
+console-password=Password:
+console-otp=One Time Password:
+console-new-password=New Password:
+console-confirm-password=Confirm Password:
+console-update-password=Update of your password is required.
+console-verify-email=You are required to verify your email address. An email has been sent to {0} that contains a verification code. Please enter this code into the input below.
+console-email-code=Email Code:
+console-accept-terms=Accept Terms? [y/n]:
+console-accept=y
\ No newline at end of file