diff --git a/adapters/oidc/cli-sso/login.sh b/adapters/oidc/cli-sso/login.sh deleted file mode 100755 index ff33a015e9..0000000000 --- a/adapters/oidc/cli-sso/login.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -export KC_AUTH_SERVER=http://localhost:8080/auth -export KC_REALM=master -export KC_CLIENT=cli - -export KC_ACCESS_TOKEN=`java -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` - - - - diff --git a/adapters/oidc/cli-sso/logout.sh b/adapters/oidc/cli-sso/logout.sh deleted file mode 100644 index ca99f88b76..0000000000 --- a/adapters/oidc/cli-sso/logout.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -java -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar logout - -unset KC_ACCESS_TOKEN - - - - 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 0000000000..4693f62512 --- /dev/null +++ b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KcinitDriver.java @@ -0,0 +1,702 @@ +/* + * 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 + *

+ * 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. + *

+ * 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 + *

+ * + * @author Bill Burke + * @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 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 = args; + + + if (args.length == 0) { + printHelp(); + return; + } + + if (args[0].equalsIgnoreCase("token")) { + //System.err.println("executing token"); + token(); + } else if (args[0].equalsIgnoreCase("login")) { + login(); + } 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 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(" login [-f] -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 creds = new HashMap<>(); + creds.put("secret", secret); + config.setCredentials(creds); + } else { + config.setPublicClient(true); + } + return config; + } + + private Map 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) 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(); + this.args = Arrays.copyOfRange(this.args, 1, this.args.length); + for (String arg : args) { + 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); + } + } + + 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/KeycloakCliSso.java b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java deleted file mode 100644 index f2b1bdc121..0000000000 --- a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java +++ /dev/null @@ -1,281 +0,0 @@ -/* - * 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.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.KeycloakDeploymentBuilder; -import org.keycloak.adapters.ServerRequest; -import org.keycloak.common.util.Time; -import org.keycloak.representations.AccessTokenResponse; -import org.keycloak.representations.adapters.config.AdapterConfig; -import org.keycloak.util.JsonSerialization; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * - * - * - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class KeycloakCliSso { - - public void mainCmd(String[] args) throws Exception { - if (args.length != 1) { - printHelp(); - return; - } - - if (args[0].equalsIgnoreCase("login")) { - login(); - } else if (args[0].equalsIgnoreCase("login-manual")) { - loginManual(); - } - /* - else if (args[0].equalsIgnoreCase("login-cli")) { - loginCli(); - } - */ - else if (args[0].equalsIgnoreCase("token")) { - token(); - } else if (args[0].equalsIgnoreCase("logout")) { - logout(); - } else if (args[0].equalsIgnoreCase("env")) { - System.out.println(System.getenv().toString()); - } else { - printHelp(); - } - } - - - public void printHelp() { - System.err.println("Commands:"); - System.err.println(" login - login with desktop browser if available, otherwise do manual login. Output is access token."); - System.err.println(" login-manual - manual login"); - //System.err.println(" login-cli - attempt Keycloak proprietary cli protocol. Otherwise do normal login"); - System.err.println(" token - print access token if logged in"); - System.err.println(" logout - logout."); - System.exit(1); - } - - public AdapterConfig getConfig() { - String url = System.getProperty("KEYCLOAK_AUTH_SERVER"); - if (url == null) { - System.err.println("KEYCLOAK_AUTH_SERVER property not set"); - System.exit(1); - } - String realm = System.getProperty("KEYCLOAK_REALM"); - if (realm == null) { - System.err.println("KEYCLOAK_REALM property not set"); - System.exit(1); - - } - String client = System.getProperty("KEYCLOAK_CLIENT"); - if (client == null) { - System.err.println("KEYCLOAK_CLIENT property not set"); - System.exit(1); - } - String secret = System.getProperty("KEYCLOAK_CLIENT_SECRET"); - - - - AdapterConfig config = new AdapterConfig(); - config.setAuthServerUrl(url); - config.setRealm(realm); - config.setResource(client); - config.setSslRequired("external"); - if (secret != null) { - Map creds = new HashMap<>(); - creds.put("secret", secret); - config.setCredentials(creds); - } else { - config.setPublicClient(true); - } - return config; - } - - public boolean checkToken(boolean outputToken) throws Exception { - String token = getTokenResponse(); - if (token == null) return false; - - - if (token != null) { - Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); - if (m.find()) { - String json = m.group(0); - try { - AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); - if (Time.currentTime() < tokenResponse.getExpiresIn()) { - return true; - } - AdapterConfig config = getConfig(); - KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config)); - installed.refreshToken(tokenResponse.getRefreshToken()); - processResponse(installed, outputToken); - return true; - } catch (Exception e) { - System.err.println("Error processing existing token"); - e.printStackTrace(); - } - - } - } - return false; - - } - - private String getTokenResponse() throws IOException { - String token = null; - File tokenFile = getTokenFilePath(); - if (tokenFile.exists()) { - FileInputStream fis = new FileInputStream(tokenFile); - byte[] data = new byte[(int) tokenFile.length()]; - fis.read(data); - fis.close(); - token = new String(data, "UTF-8"); - } - return token; - } - - public void token() throws Exception { - String token = getTokenResponse(); - if (token == null) { - System.err.println("There is no token for client"); - System.exit(1); - } else { - Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); - if (m.find()) { - String json = m.group(0); - try { - AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); - if (Time.currentTime() < tokenResponse.getExpiresIn()) { - System.out.println(tokenResponse.getToken()); - return; - } else { - System.err.println("token in response file is expired"); - System.exit(1); - } - } catch (Exception e) { - System.err.println("Failure processing token response file"); - e.printStackTrace(); - System.exit(1); - } - } else { - System.err.println("Could not find json within token response file"); - System.exit(1); - } - } - } - - public void login() throws Exception { - if (checkToken(true)) return; - AdapterConfig config = getConfig(); - KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config)); - installed.login(); - processResponse(installed, true); - } - - public void loginCli() throws Exception { - if (checkToken(false)) return; - AdapterConfig config = getConfig(); - KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config)); - if (!installed.loginCommandLine()) installed.login(); - processResponse(installed, false); - } - - 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 File getTokenDirectory() { - return Paths.get(getHome(), System.getProperty("basepath", ".keycloak-sso"), System.getProperty("KEYCLOAK_REALM")).toFile(); - } - - public File getTokenFilePath() { - return Paths.get(getHome(), System.getProperty("basepath", ".keycloak-sso"), System.getProperty("KEYCLOAK_REALM"), System.getProperty("KEYCLOAK_CLIENT") + ".json").toFile(); - } - - private void processResponse(KeycloakInstalled installed, boolean outputToken) throws IOException { - AccessTokenResponse tokenResponse = installed.getTokenResponse(); - tokenResponse.setExpiresIn(Time.currentTime() + tokenResponse.getExpiresIn()); - tokenResponse.setIdToken(null); - String output = JsonSerialization.writeValueAsString(tokenResponse); - getTokenDirectory().mkdirs(); - FileOutputStream fos = new FileOutputStream(getTokenFilePath()); - fos.write(output.getBytes("UTF-8")); - fos.flush(); - fos.close(); - if (outputToken) System.out.println(tokenResponse.getToken()); - } - - public void loginManual() throws Exception { - if (checkToken(true)) return; - AdapterConfig config = getConfig(); - KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config); - KeycloakInstalled installed = new KeycloakInstalled(deployment); - installed.loginManual(); - processResponse(installed, true); - } - - public void logout() throws Exception { - String token = getTokenResponse(); - if (token != null) { - Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); - if (m.find()) { - String json = m.group(0); - try { - AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); - if (Time.currentTime() > tokenResponse.getExpiresIn()) { - System.err.println("Login is expired"); - System.exit(1); - } - AdapterConfig config = getConfig(); - KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config); - ServerRequest.invokeLogout(deployment, tokenResponse.getRefreshToken()); - for (File fp : getTokenDirectory().listFiles()) fp.delete(); - System.out.println("logout complete"); - } catch (Exception e) { - System.err.println("Failure processing token response file"); - e.printStackTrace(); - System.exit(1); - } - } else { - System.err.println("Could not find json within token response file"); - System.exit(1); - } - } else { - System.err.println("Not logged in"); - System.exit(1); - } - } -} 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 f1fee42161..4f311c223f 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,116 @@ 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); + 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"); } - form.param(param, value); + return false; + } 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; + } + 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 +583,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 +635,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 +656,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 { diff --git a/adapters/oidc/cli-sso/README.md b/adapters/oidc/kcinit/README.md similarity index 100% rename from adapters/oidc/cli-sso/README.md rename to adapters/oidc/kcinit/README.md diff --git a/adapters/oidc/cli-sso/pom.xml b/adapters/oidc/kcinit/pom.xml similarity index 95% rename from adapters/oidc/cli-sso/pom.xml rename to adapters/oidc/kcinit/pom.xml index c8d6016dc2..75dcf4d387 100755 --- a/adapters/oidc/cli-sso/pom.xml +++ b/adapters/oidc/kcinit/pom.xml @@ -26,7 +26,7 @@ 4.0.0 - keycloak-cli-sso + kcinit Keycloak CLI SSO Framework @@ -54,7 +54,7 @@ - org.keycloak.adapters.KeycloakCliSsoMain + org.keycloak.adapters.KcinitMain diff --git a/adapters/oidc/kcinit/src/main/bin/kcinit b/adapters/oidc/kcinit/src/main/bin/kcinit new file mode 100755 index 0000000000..4f5c2c6a72 --- /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 0000000000..90553091cc --- /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 %* diff --git a/adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java b/adapters/oidc/kcinit/src/main/java/org/keycloak/adapters/KcinitMain.java similarity index 56% rename from adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java rename to adapters/oidc/kcinit/src/main/java/org/keycloak/adapters/KcinitMain.java index 3aaeb9b18a..275956f01f 100644 --- a/adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java +++ b/adapters/oidc/kcinit/src/main/java/org/keycloak/adapters/KcinitMain.java @@ -16,30 +16,15 @@ */ package org.keycloak.adapters; -import org.keycloak.adapters.installed.KeycloakCliSso; -import org.keycloak.adapters.installed.KeycloakInstalled; -import org.keycloak.common.util.Time; -import org.keycloak.representations.AccessTokenResponse; -import org.keycloak.representations.adapters.config.AdapterConfig; -import org.keycloak.util.JsonSerialization; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import org.keycloak.adapters.installed.KcinitDriver; /** * @author Bill Burke * @version $Revision: 1 $ */ -public class KeycloakCliSsoMain extends KeycloakCliSso { +public class KcinitMain extends KcinitDriver { public static void main(String[] args) throws Exception { - new KeycloakCliSsoMain().mainCmd(args); + new KcinitMain().mainCmd(args); } } diff --git a/adapters/oidc/pom.xml b/adapters/oidc/pom.xml index c380735326..6bc847388a 100755 --- a/adapters/oidc/pom.xml +++ b/adapters/oidc/pom.xml @@ -34,7 +34,7 @@ adapter-core as7-eap6 installed - cli-sso + kcinit jaxrs-oauth-client jetty js 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 0000000000..70ce02d794 --- /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/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index df5411257d..2ef01cbb1f 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"; @@ -114,6 +116,7 @@ public interface OAuth2Constants { String UMA_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket"; + String DISPLAY_CONSOLE = "console"; } 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 75759dd556..8d954eaba8 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 Marek Posolda */ @@ -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/test/java/org/keycloak/jose/JWETest.java b/core/src/test/java/org/keycloak/jose/JWETest.java index 31d8a8ac13..cc179bf988 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 Marek Posolda @@ -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 { @@ -118,10 +117,25 @@ public class JWETest { System.out.println("Iterations: " + iterations + ", took: " + took); } + @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 { diff --git a/pom.xml b/pom.xml index eab1938cf8..ddc8d25bc6 100755 --- a/pom.xml +++ b/pom.xml @@ -1414,6 +1414,17 @@ ${project.version} zip + + org.keycloak + kcinit + ${project.version} + + + org.keycloak + kcinit-dist + ${project.version} + zip + diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java index 2f17f77df1..1d600525e2 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java @@ -43,5 +43,6 @@ public enum AuthenticationFlowError { IDENTITY_PROVIDER_NOT_FOUND, IDENTITY_PROVIDER_DISABLED, - IDENTITY_PROVIDER_ERROR + IDENTITY_PROVIDER_ERROR, + DISPLAY_NOT_SUPPORTED } diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowException.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowException.java index bf8fbcf81b..e15386a74f 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowException.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowException.java @@ -17,6 +17,8 @@ package org.keycloak.authentication; +import javax.ws.rs.core.Response; + /** * Throw this exception from an Authenticator, FormAuthenticator, or FormAction if you want to completely abort the flow. * @@ -25,11 +27,17 @@ package org.keycloak.authentication; */ public class AuthenticationFlowException extends RuntimeException { private AuthenticationFlowError error; + private Response response; public AuthenticationFlowException(AuthenticationFlowError error) { this.error = error; } + public AuthenticationFlowException(AuthenticationFlowError error, Response response) { + this.error = error; + this.response = response; + } + public AuthenticationFlowException(String message, AuthenticationFlowError error) { super(message); this.error = error; @@ -53,4 +61,8 @@ public class AuthenticationFlowException extends RuntimeException { public AuthenticationFlowError getError() { return error; } + + public Response getResponse() { + return response; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeAuthenticatorFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeAuthenticatorFactory.java new file mode 100644 index 0000000000..07543834f0 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeAuthenticatorFactory.java @@ -0,0 +1,21 @@ +package org.keycloak.authentication; + +import org.keycloak.models.KeycloakSession; + +/** + * Implement this interface when declaring your authenticator factory + * if your provider has support for multiple oidc display query parameter parameter types + * if the display query parameter is set and your factory implements this interface, this method + * will be called. + * + */ +public interface DisplayTypeAuthenticatorFactory { + /** + * + * + * @param session + * @param displayType i.e. "console", "wap", "popup" are examples + * @return null if display type isn't support. + */ + Authenticator createDisplay(KeycloakSession session, String displayType); +} diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeRequiredActionFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeRequiredActionFactory.java new file mode 100644 index 0000000000..e22e9fff7e --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/authentication/DisplayTypeRequiredActionFactory.java @@ -0,0 +1,13 @@ +package org.keycloak.authentication; + +import org.keycloak.models.KeycloakSession; + +/** + * Implement this interface when declaring your required action factory + * has support for multiple oidc display query parameter parameter types + * if the display query parameter is set and your factory implements this interface, this method + * will be called. + */ +public interface DisplayTypeRequiredActionFactory { + RequiredActionProvider createDisplay(KeycloakSession session, String displayType); +} 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 caaa14e59c..1289842868 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 @@ -59,6 +59,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 * 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 0000000000..b1dc9a2d15 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/authentication/TextChallenge.java @@ -0,0 +1,303 @@ +package org.keycloak.authentication; + +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; + +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 { + + /** + * Browser is required to login. This will abort client from doing a console login. + * + * @param session + * @return + */ + public static Response browserRequired(KeycloakSession session) { + return Response.status(Response.Status.UNAUTHORIZED) + .header("WWW-Authenticate", "X-Text-Form-Challenge browserRequired") + .type(MediaType.TEXT_PLAIN) + .entity("\n" + session.getProvider(LoginFormsProvider.class).getMessage("browserRequired") + "\n").build(); + } + + + /** + * 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 a60ebc0677..425ec523f9 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 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 subjectAttributes, String bodyTemplate, Map bodyAttributes) throws EmailException; } diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java index 632c21c27e..95bcd8131f 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java @@ -90,5 +90,6 @@ public interface Errors { String NOT_LOGGED_IN = "not_logged_in"; String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider"; String ILLEGAL_ORIGIN = "illegal_origin"; + String DISPLAY_UNSUPPORTED = "display_unsupported"; } 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 256b87fe7f..31f430d920 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 5cb1ec5903..ae2f4c7295 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/AuthenticationProcessor.java b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java index 537581a72f..db96f11e68 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java @@ -653,27 +653,33 @@ public class AuthenticationProcessor { public Response handleBrowserException(Exception failure) { if (failure instanceof AuthenticationFlowException) { AuthenticationFlowException e = (AuthenticationFlowException) failure; + if (e.getError() == AuthenticationFlowError.INVALID_USER) { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.USER_NOT_FOUND); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_USER); } else if (e.getError() == AuthenticationFlowError.USER_DISABLED) { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.USER_DISABLED); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session,authenticationSession, Response.Status.BAD_REQUEST, Messages.ACCOUNT_DISABLED); } else if (e.getError() == AuthenticationFlowError.USER_TEMPORARILY_DISABLED) { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.USER_TEMPORARILY_DISABLED); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session,authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_USER); } else if (e.getError() == AuthenticationFlowError.INVALID_CLIENT_SESSION) { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.INVALID_CODE); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_CODE); } else if (e.getError() == AuthenticationFlowError.EXPIRED_CODE) { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.EXPIRED_CODE); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.EXPIRED_CODE); } else if (e.getError() == AuthenticationFlowError.FORK_FLOW) { @@ -701,9 +707,15 @@ public class AuthenticationProcessor { CacheControlUtil.noBackButtonCacheControlHeader(); return processor.authenticate(); + } else if (e.getError() == AuthenticationFlowError.DISPLAY_NOT_SUPPORTED) { + ServicesLogger.LOGGER.failedAuthentication(e); + event.error(Errors.DISPLAY_UNSUPPORTED); + if (e.getResponse() != null) return e.getResponse(); + return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.DISPLAY_UNSUPPORTED); } else { ServicesLogger.LOGGER.failedAuthentication(e); event.error(Errors.INVALID_USER_CREDENTIALS); + if (e.getResponse() != null) return e.getResponse(); return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_USER); } diff --git a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java index 89471e7a8f..ac0c5e1a4b 100755 --- a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java +++ b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java @@ -18,6 +18,7 @@ package org.keycloak.authentication; import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.UserModel; @@ -58,6 +59,24 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { || status == AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED; } + protected Authenticator createAuthenticator(AuthenticatorFactory factory) { + String display = processor.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY); + if (display == null) return factory.create(processor.getSession()); + + + if (factory instanceof DisplayTypeAuthenticatorFactory) { + Authenticator authenticator = ((DisplayTypeAuthenticatorFactory)factory).createDisplay(processor.getSession(), display); + if (authenticator != null) return authenticator; + } + // todo create a provider for handling lack of display support + if (OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(display)) { + throw new AuthenticationFlowException(AuthenticationFlowError.DISPLAY_NOT_SUPPORTED, TextChallenge.browserRequired(processor.getSession())); + + } else { + return factory.create(processor.getSession()); + } + } + @Override public Response processAction(String actionExecution) { @@ -86,7 +105,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { if (factory == null) { throw new RuntimeException("Unable to find factory for AuthenticatorFactory: " + model.getAuthenticator() + " did you forget to declare it in a META-INF/services file?"); } - Authenticator authenticator = factory.create(processor.getSession()); + Authenticator authenticator = createAuthenticator(factory); AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, authenticator, executions); logger.debugv("action: {0}", model.getAuthenticator()); authenticator.action(result); @@ -161,7 +180,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { if (factory == null) { throw new RuntimeException("Unable to find factory for AuthenticatorFactory: " + model.getAuthenticator() + " did you forget to declare it in a META-INF/services file?"); } - Authenticator authenticator = factory.create(processor.getSession()); + Authenticator authenticator = createAuthenticator(factory); logger.debugv("authenticator: {0}", factory.getId()); UserModel authUser = processor.getAuthenticationSession().getAuthenticatedUser(); diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java index 5e9a546a0c..38b9c2fe0e 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; @@ -162,6 +163,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(); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/AttemptedAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/AttemptedAuthenticator.java new file mode 100644 index 0000000000..fc866fd409 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/AttemptedAuthenticator.java @@ -0,0 +1,46 @@ +package org.keycloak.authentication.authenticators; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +/** + * Pass-thru atheneticator that just sets the context to attempted. + */ +public class AttemptedAuthenticator implements Authenticator { + + public static final AttemptedAuthenticator SINGLETON = new AttemptedAuthenticator(); + @Override + public void authenticate(AuthenticationFlowContext context) { + context.attempted(); + + } + + @Override + public void action(AuthenticationFlowContext context) { + throw new RuntimeException("Unreachable!"); + + } + + @Override + public boolean requiresUser() { + return false; + } + + @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/browser/CookieAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticatorFactory.java index 7e40298ac8..b87dbe9206 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticatorFactory.java @@ -18,8 +18,11 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.Config; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.DisplayTypeAuthenticatorFactory; +import org.keycloak.authentication.authenticators.AttemptedAuthenticator; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -31,7 +34,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class CookieAuthenticatorFactory implements AuthenticatorFactory { +public class CookieAuthenticatorFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory { public static final String PROVIDER_ID = "auth-cookie"; static CookieAuthenticator SINGLETON = new CookieAuthenticator(); @@ -40,6 +43,13 @@ public class CookieAuthenticatorFactory implements AuthenticatorFactory { return SINGLETON; } + @Override + public Authenticator createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return SINGLETON; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return AttemptedAuthenticator.SINGLETON; // ignore this authenticator + } + @Override public void init(Config.Scope config) { 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 52183479f1..170f9d7489 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/IdentityProviderAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticatorFactory.java index 635c95e3d7..b136d33177 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/IdentityProviderAuthenticatorFactory.java @@ -18,8 +18,11 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.Config; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.DisplayTypeAuthenticatorFactory; +import org.keycloak.authentication.authenticators.AttemptedAuthenticator; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -33,7 +36,7 @@ import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; /** * @author Stian Thorgersen */ -public class IdentityProviderAuthenticatorFactory implements AuthenticatorFactory { +public class IdentityProviderAuthenticatorFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory { protected static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { AuthenticationExecutionModel.Requirement.ALTERNATIVE, AuthenticationExecutionModel.Requirement.DISABLED @@ -82,6 +85,13 @@ public class IdentityProviderAuthenticatorFactory implements AuthenticatorFactor return new IdentityProviderAuthenticator(); } + @Override + public Authenticator createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return new IdentityProviderAuthenticator(); + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return AttemptedAuthenticator.SINGLETON; // ignore this authenticator + } + @Override public void init(Config.Scope config) { } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticatorFactory.java index f443d28e9f..d71659ce0b 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/OTPFormAuthenticatorFactory.java @@ -18,8 +18,11 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.Config; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.DisplayTypeAuthenticatorFactory; +import org.keycloak.authentication.authenticators.console.ConsoleOTPFormAuthenticator; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -32,7 +35,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class OTPFormAuthenticatorFactory implements AuthenticatorFactory { +public class OTPFormAuthenticatorFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory { public static final String PROVIDER_ID = "auth-otp-form"; public static final OTPFormAuthenticator SINGLETON = new OTPFormAuthenticator(); @@ -42,6 +45,13 @@ public class OTPFormAuthenticatorFactory implements AuthenticatorFactory { return SINGLETON; } + @Override + public Authenticator createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return SINGLETON; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleOTPFormAuthenticator.SINGLETON; + } + @Override public void init(Config.Scope config) { diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticatorFactory.java index 9d837c62fa..ae5dd0c297 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/SpnegoAuthenticatorFactory.java @@ -18,8 +18,10 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.Config; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.DisplayTypeAuthenticatorFactory; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -32,7 +34,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class SpnegoAuthenticatorFactory implements AuthenticatorFactory { +public class SpnegoAuthenticatorFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory { public static final String PROVIDER_ID = "auth-spnego"; public static final SpnegoAuthenticator SINGLETON = new SpnegoAuthenticator(); @@ -42,6 +44,13 @@ public class SpnegoAuthenticatorFactory implements AuthenticatorFactory { return SINGLETON; } + @Override + public Authenticator createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return SINGLETON; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return SINGLETON; + } + @Override public void init(Config.Scope config) { 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 bd81263109..43383a0e30 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,7 +19,6 @@ 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.forms.login.LoginFormsProvider; import org.keycloak.models.KeycloakSession; diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordFormFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordFormFactory.java index ef0c9b1ef0..fe42f48e21 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordFormFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordFormFactory.java @@ -18,8 +18,11 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.Config; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.DisplayTypeAuthenticatorFactory; +import org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticator; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -32,7 +35,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class UsernamePasswordFormFactory implements AuthenticatorFactory { +public class UsernamePasswordFormFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory { public static final String PROVIDER_ID = "auth-username-password-form"; public static final UsernamePasswordForm SINGLETON = new UsernamePasswordForm(); @@ -42,6 +45,13 @@ public class UsernamePasswordFormFactory implements AuthenticatorFactory { return SINGLETON; } + @Override + public Authenticator createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return SINGLETON; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleUsernamePasswordAuthenticator.SINGLETON; + } + @Override public void init(Config.Scope config) { 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 0000000000..fff2c80e75 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/console/ConsoleOTPFormAuthenticator.java @@ -0,0 +1,73 @@ +/* + * 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.TextChallenge; +import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator; +import org.keycloak.representations.idm.CredentialRepresentation; + +import javax.ws.rs.core.Response; +import java.net.URI; + +/** + * @author Bill Burke + * @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 0000000000..4595df58f1 --- /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 Bill Burke + * @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 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 0000000000..05aa235723 --- /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 Bill Burke + * @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 getConfigProperties() { + return null; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + +} 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 0000000000..24c6938771 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleTermsAndConditions.java @@ -0,0 +1,77 @@ +/* + * 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 Bill Burke + * @version $Revision: 1 $ + */ +public class ConsoleTermsAndConditions implements RequiredActionProvider { + public static final ConsoleTermsAndConditions SINGLETON = new ConsoleTermsAndConditions(); + public static final String USER_ATTRIBUTE = TermsAndConditions.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 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 0000000000..d499eadaaf --- /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 Bill Burke + * @version $Revision: 1 $ + */ +public class ConsoleUpdatePassword extends UpdatePassword implements RequiredActionProvider { + 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 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 0000000000..0b66beff51 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateProfile.java @@ -0,0 +1,68 @@ +/* + * 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 Bill Burke + * @version $Revision: 1 $ + */ +public class ConsoleUpdateProfile implements RequiredActionProvider { + 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() { + + } +} 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 0000000000..89ef89b6b1 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleUpdateTotp.java @@ -0,0 +1,113 @@ +/* + * 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 Bill Burke + * @version $Revision: 1 $ + */ +public class ConsoleUpdateTotp implements RequiredActionProvider { + 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 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() { + + } +} 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 0000000000..e136cebf67 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/ConsoleVerifyEmail.java @@ -0,0 +1,152 @@ +/* + * 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 Bill Burke + * @version $Revision: 1 $ + */ +public class ConsoleVerifyEmail implements RequiredActionProvider { + 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 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() { + + } + + public static String EMAIL_CODE="email_code"; + 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 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 f4a156665f..2f687821d5 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/TermsAndConditions.java @@ -18,9 +18,8 @@ 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.OAuth2Constants; +import org.keycloak.authentication.*; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -32,7 +31,7 @@ import java.util.Arrays; * @author Bill Burke * @version $Revision: 1 $ */ -public class TermsAndConditions implements RequiredActionProvider, RequiredActionFactory { +public class TermsAndConditions implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { public static final String PROVIDER_ID = "terms_and_conditions"; public static final String USER_ATTRIBUTE = PROVIDER_ID; @@ -41,6 +40,15 @@ public class TermsAndConditions implements RequiredActionProvider, RequiredActio return this; } + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleTermsAndConditions.SINGLETON; + } + + + @Override public void init(Config.Scope config) { 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 1e9f37a146..45fb05f436 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java @@ -19,9 +19,8 @@ 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.OAuth2Constants; +import org.keycloak.authentication.*; import org.keycloak.common.util.Time; import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialProvider; @@ -47,7 +46,7 @@ import java.util.concurrent.TimeUnit; * @author Bill Burke * @version $Revision: 1 $ */ -public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory { +public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { private static final Logger logger = Logger.getLogger(UpdatePassword.class); @Override public void evaluateTriggers(RequiredActionContext context) { @@ -142,6 +141,15 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac return this; } + + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleUpdatePassword.SINGLETON; + } + + @Override public void init(Config.Scope config) { 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 3ed1f12aa6..ccaf7291a5 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java @@ -18,9 +18,8 @@ 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.OAuth2Constants; +import org.keycloak.authentication.*; import org.keycloak.events.Details; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; @@ -41,7 +40,7 @@ import java.util.List; * @author Bill Burke * @version $Revision: 1 $ */ -public class UpdateProfile implements RequiredActionProvider, RequiredActionFactory { +public class UpdateProfile implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { @Override public void evaluateTriggers(RequiredActionContext context) { } @@ -142,6 +141,16 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact return this; } + + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleUpdateProfile.SINGLETON; + } + + + @Override public void init(Config.Scope config) { 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 e85ec7e4e5..188ba2a2d5 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java @@ -18,9 +18,8 @@ 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.OAuth2Constants; +import org.keycloak.authentication.*; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.KeycloakSession; @@ -38,7 +37,7 @@ import javax.ws.rs.core.Response; * @author Bill Burke * @version $Revision: 1 $ */ -public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory { +public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { @Override public void evaluateTriggers(RequiredActionContext context) { } @@ -105,6 +104,15 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory return this; } + + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleUpdateTotp.SINGLETON; + } + + @Override public void init(Config.Scope config) { 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 969f3505a4..c29c616743 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -19,9 +19,8 @@ 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.OAuth2Constants; +import org.keycloak.authentication.*; import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken; import org.keycloak.common.util.Time; import org.keycloak.email.EmailException; @@ -45,7 +44,7 @@ import javax.ws.rs.core.*; * @author Bill Burke * @version $Revision: 1 $ */ -public class VerifyEmail implements RequiredActionProvider, RequiredActionFactory { +public class VerifyEmail implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { private static final Logger logger = Logger.getLogger(VerifyEmail.class); @Override public void evaluateTriggers(RequiredActionContext context) { @@ -107,6 +106,14 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor return this; } + + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleVerifyEmail.SINGLETON; + } + @Override public void init(Config.Scope config) { 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 c55a5854fc..e3e447f996 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 attributes) throws EmailException { - send(subjectKey, Collections.emptyList(), template, attributes); + @Override + public void send(String subjectFormatKey, String bodyTemplate, Map bodyAttributes) throws EmailException { + send(subjectFormatKey, Collections.emptyList(), bodyTemplate, bodyAttributes); } protected EmailTemplate processTemplate(String subjectKey, List subjectAttributes, String template, Map 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 subjectAttributes, String template, Map attributes) throws EmailException { + @Override + public void send(String subjectFormatKey, List subjectAttributes, String bodyTemplate, Map 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 cf480fcbcf..3d0f52d819 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/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 148d840ace..56c002280d 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/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index cf0bb4a68d..65c66e28e5 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/LogoutEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java index 7f5048a92e..406202102e 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java @@ -164,12 +164,11 @@ public class LogoutEndpoint { * * returns 204 if successful, 400 if not with a json error response. * - * @param authorizationHeader * @return */ @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response logoutToken(final @HeaderParam(HttpHeaders.AUTHORIZATION) String authorizationHeader) { + public Response logoutToken() { MultivaluedMap form = request.getDecodedFormParameters(); checkSsl(); 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 05f9f4c4dc..38d2489d9d 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/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index 29edb0331b..083c2c3a0a 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 90160ee888..d6cb1b77e0 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/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 803e778115..edb9b511bb 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -21,11 +21,7 @@ import org.jboss.resteasy.specimpl.MultivaluedMapImpl; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.AuthenticationProcessor; -import org.keycloak.authentication.RequiredActionContext; -import org.keycloak.authentication.RequiredActionContextResult; -import org.keycloak.authentication.RequiredActionFactory; -import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.authentication.*; import org.keycloak.authentication.actiontoken.DefaultActionTokenKey; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.common.ClientConnection; @@ -761,6 +757,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(); @@ -965,6 +966,25 @@ public class AuthenticationManager { authSession.setProtocolMappers(requestedProtocolMappers); } + public static RequiredActionProvider createRequiredAction(KeycloakSession session, RequiredActionFactory factory, AuthenticationSessionModel authSession) { + String display = authSession.getClientNote(OAuth2Constants.DISPLAY); + if (display == null) return factory.create(session); + + + if (factory instanceof DisplayTypeRequiredActionFactory) { + RequiredActionProvider provider = ((DisplayTypeRequiredActionFactory)factory).createDisplay(session, display); + if (provider != null) return provider; + } + // todo create a provider for handling lack of display support + if (OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(display)) { + throw new AuthenticationFlowException(AuthenticationFlowError.DISPLAY_NOT_SUPPORTED, TextChallenge.browserRequired(session)); + + } else { + return factory.create(session); + } + } + + protected static Response executionActions(KeycloakSession session, AuthenticationSessionModel authSession, HttpRequest request, EventBuilder event, RealmModel realm, UserModel user, Set requiredActions) { @@ -982,7 +1002,15 @@ public class AuthenticationManager { if (factory == null) { throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?"); } - RequiredActionProvider actionProvider = factory.create(session); + RequiredActionProvider actionProvider = null; + try { + actionProvider = createRequiredAction(session, factory, authSession); + } catch (AuthenticationFlowException e) { + if (e.getResponse() != null) { + return e.getResponse(); + } + throw e; + } RequiredActionContextResult context = new RequiredActionContextResult(authSession, realm, event, session, request, user, factory); actionProvider.requiredActionChallenge(context); diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index c6fb6c89a4..425a88927f 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -21,6 +21,7 @@ package org.keycloak.services.messages; */ public class Messages { + public static final String DISPLAY_UNSUPPORTED = "displayUnsupported"; public static final String LOGIN_TIMEOUT = "loginTimeout"; public static final String INVALID_USER = "invalidUserMessage"; diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 7c38ba8be9..ef9525a96c 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -16,17 +16,12 @@ */ package org.keycloak.services.resources; +import org.keycloak.authentication.*; import org.keycloak.authentication.actiontoken.DefaultActionTokenKey; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.OAuth2Constants; -import org.keycloak.authentication.AuthenticationProcessor; -import org.keycloak.authentication.RequiredActionContext; -import org.keycloak.authentication.RequiredActionContextResult; -import org.keycloak.authentication.RequiredActionFactory; -import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.ExplainedVerificationException; import org.keycloak.authentication.actiontoken.*; import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionTokenHandler; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; @@ -934,7 +929,15 @@ public class LoginActionsService { event.error(Errors.INVALID_CODE); throw new WebApplicationException(ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.INVALID_CODE)); } - RequiredActionProvider provider = factory.create(session); + RequiredActionProvider provider = null; + try { + provider = AuthenticationManager.createRequiredAction(session, factory, authSession); + } catch (AuthenticationFlowException e) { + if (e.getResponse() != null) { + return e.getResponse(); + } + throw new WebApplicationException(ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.DISPLAY_UNSUPPORTED)); + } RequiredActionContextResult context = new RequiredActionContextResult(authSession, realm, event, session, request, authSession.getAuthenticatedUser(), factory) { @Override diff --git a/services/src/main/java/org/keycloak/utils/TotpUtils.java b/services/src/main/java/org/keycloak/utils/TotpUtils.java index 67ff6979bf..d076735654 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 76b75076ee..ee29448342 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 d3794749f6..1e5d7552d4 100644 --- a/testsuite/integration-arquillian/tests/base/pom.xml +++ b/testsuite/integration-arquillian/tests/base/pom.xml @@ -228,11 +228,47 @@ zip ${containers.home} - + + + com.igormaznitsa + mvn-golang-wrapper + 2.1.6 + true + + 1.9.2 + + + + get-mousetrap + + get + + + + github.com/inconshreveable/mousetrap + + ${project.build.directory}/gopath + + + + get-kcinit + + get + + + + github.com/keycloak/kcinit + + ${project.build.directory}/gopath + 0.3 + + + + 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 0000000000..513eb54d27 --- /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 Marko Strukelj + */ +public class KcinitExec extends AbstractExec { + + public static final String WORK_DIR = System.getProperty("user.dir") + "/target"; + + public static final String CMD = OS_ARCH.isWindows() ? "kcinit" : "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 { + + @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/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 b5476b5aa4..ddfe91dd73 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; @@ -33,7 +34,7 @@ public abstract class AbstractExec { private boolean logStreams = Boolean.valueOf(System.getProperty("cli.log.output", "true")); - protected boolean dumpStreams; + protected boolean dumpStreams = true; protected String workDir = WORK_DIR; @@ -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/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 2642a11ef7..e5d9471406 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -146,7 +146,7 @@ public class ProvidersTest extends AbstractAuthenticationTest { "Validates a username and password from login form."); addProviderInfo(result, "auth-x509-client-username-form", "X509/Validate Username Form", "Validates username and password from X509 client certificate received as a part of mutual SSL handshake."); - addProviderInfo(result, "cli-username-password", "Username Password Challenge", + addProviderInfo(result, "console-username-password", "Username Password Challenge", "Proprietary challenge protocol for CLI clients that queries for username password"); addProviderInfo(result, "direct-grant-auth-x509-username", "X509/Validate Username", "Validates username and password from X509 client certificate received as a part of mutual SSL handshake."); 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 0000000000..f23a63c4b2 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/KcinitTest.java @@ -0,0 +1,564 @@ +/* + * 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.forms.PassThroughAuthenticator; +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 Bill Burke + */ +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("p@p.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); + user = session.users().addUser(realm, "tbrady"); + session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password("password")); + user.setEnabled(true); + user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); + + // Parent flow + AuthenticationFlowModel browser = new AuthenticationFlowModel(); + browser.setAlias("no-console-flow"); + browser.setDescription("browser based authentication"); + browser.setProviderId("basic-flow"); + browser.setTopLevel(true); + browser.setBuiltIn(true); + browser = realm.addAuthenticationFlow(browser); + + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setParentFlow(browser.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); + execution.setPriority(20); + execution.setAuthenticator(PassThroughAuthenticator.PROVIDER_ID); + realm.addAuthenticatorExecution(execution); + + }); + } + + //@Test + public void testDemo() throws Exception { + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + Map 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(100000000); + } + + @Test + public void testBrowserRequired() throws Exception { + // that that a browser require challenge is sent back if authentication flow doesn't support console display mode + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + ClientModel kcinit = realm.getClientByClientId(KCINIT_CLIENT); + AuthenticationFlowModel flow = realm.getFlowByAlias("no-console-flow"); + kcinit.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.BROWSER_BINDING, flow.getId()); + + + }); + + testInstall(); + // login + //System.out.println("login...."); + KcinitExec exe = KcinitExec.newBuilder() + .argsLine("login") + .executeAsync(); + exe.waitCompletion(); + Assert.assertEquals(1, exe.exitCode()); + Assert.assertTrue(exe.stderrString().contains("Browser required to login")); + //Assert.assertEquals("stderr first line", "Browser required to login", exe.stderrLines().get(1)); + + + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + ClientModel kcinit = realm.getClientByClientId(KCINIT_CLIENT); + kcinit.removeAuthenticationFlowBindingOverride(AuthenticationFlowBindings.BROWSER_BINDING); + + + }); + } + + + @Test + public void testBadCommand() throws Exception { + KcinitExec exe = KcinitExec.execute("covfefe"); + Assert.assertEquals(1, exe.exitCode()); + Assert.assertEquals("stderr first line", "Error: unknown command \"covfefe\" for \"kcinit\"", 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(); + //System.out.println(exe.stderrString()); + //exe.waitForStderr("(y/n):"); + //exe.sendLine("n"); + exe.waitForStderr("Authentication server URL [http://localhost:8080/auth]:"); + exe.sendLine(OAuthClient.AUTH_SERVER_ROOT); + //System.out.println(exe.stderrString()); + exe.waitForStderr("Name of realm [master]:"); + exe.sendLine("test"); + //System.out.println(exe.stderrString()); + exe.waitForStderr("client id [kcinit]:"); + exe.sendLine(""); + //System.out.println(exe.stderrString()); + exe.waitForStderr("Client secret [none]:"); + exe.sendLine("password"); + //System.out.println(exe.stderrString()); + exe.waitCompletion(); + Assert.assertEquals(0, exe.exitCode()); + } + + @Test + public void testBasic() throws Exception { + testInstall(); + // login + //System.out.println("login...."); + KcinitExec exe = KcinitExec.newBuilder() + .argsLine("login") + .executeAsync(); + //System.out.println(exe.stderrString()); + exe.waitForStderr("Username:"); + exe.sendLine("wburke"); + //System.out.println(exe.stderrString()); + exe.waitForStderr("Password:"); + exe.sendLine("password"); + //System.out.println(exe.stderrString()); + 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), 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("login") + .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("login") + .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("login") + .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("login -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("login") + .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("login") + .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("login") + .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 72024f63f1..5855979eb2 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 0000000000..b4a01c9eee --- /dev/null +++ b/themes/src/main/resources/theme/base/email/html/email-verification-with-code.ftl @@ -0,0 +1,5 @@ + + +${msg("emailVerificationBodyCodeHtml",code)?no_esc} + + 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 e04e947a44..b2fd0c04c4 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=

Please verify your email address by entering in the following code.

{0}

+ 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 0000000000..4ffb7d8798 --- /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 0000000000..d609182edf --- /dev/null +++ b/themes/src/main/resources/theme/base/login/login-config-totp-text.ftl @@ -0,0 +1,31 @@ +<#ftl output_format="plainText"> +${msg("loginTotpIntro")} + +${msg("loginTotpStep1")} + +<#list totp.policy.supportedApplications as app> +* ${app} + + +${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} + + + +Enter in your one time password so we can verify you have installed it correctly. + + + 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 0000000000..87abcd7864 --- /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 d4c12e47be..5f4c6a2f6d 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 @@ -34,9 +34,12 @@ emailForgotTitle=Forgot Your Password? updatePasswordTitle=Update password codeSuccessTitle=Success code codeErrorTitle=Error code\: {0} +displayUnsupported=Requested display type unsupported +browserRequired=Browser required to login termsTitle=Terms and Conditions termsText=

Terms and conditions to be defined

+termsPlainText=Terms and conditions to be defined. recaptchaFailed=Invalid Recaptcha recaptchaNotConfigured=Recaptcha is required, but not configured @@ -66,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 @@ -278,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