ConsoleDisplayMode.java

325 lines | 10.095 kB Blame History Raw Download
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 ConsoleDisplayMode {

    /**
     * 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();
    }

    /**
     * Browser is required to continue login.  This will prompt client on whether to continue with a browser or abort.
     *
     * @param session
     * @param callback
     * @return
     */
    public static Response browserContinue(KeycloakSession session, String callback) {
        String browserContinueMsg = session.getProvider(LoginFormsProvider.class).getMessage("browserContinue");
        String browserPrompt = session.getProvider(LoginFormsProvider.class).getMessage("browserContinuePrompt");
        String answer = session.getProvider(LoginFormsProvider.class).getMessage("browserContinueAnswer");

        String header = "X-Text-Form-Challenge callback=\"" + callback + "\"";
        header += " browserContinue=\"" + browserPrompt + "\" answer=\"" + answer + "\"";
        return Response.status(Response.Status.UNAUTHORIZED)
                .header("WWW-Authenticate", header)
                .type(MediaType.TEXT_PLAIN)
                .entity("\n" + browserContinueMsg + "\n").build();
    }



    /**
     * Build challenge response for required actions
     *
     * @param context
     * @return
     */
    public static ConsoleDisplayMode challenge(RequiredActionContext context) {
        return new ConsoleDisplayMode(context);

    }

    /**
     * Build challenge response for authentication flows
     *
     * @param context
     * @return
     */
    public static ConsoleDisplayMode challenge(AuthenticationFlowContext context) {
        return new ConsoleDisplayMode(context);

    }
    /**
     * Build challenge response header only for required actions
     *
     * @param context
     * @return
     */
    public static HeaderBuilder header(RequiredActionContext context) {
        return new ConsoleDisplayMode(context).header();

    }

    /**
     * Build challenge response header only for authentication flows
     *
     * @param context
     * @return
     */
    public static HeaderBuilder header(AuthenticationFlowContext context) {
        return new ConsoleDisplayMode(context).header();

    }
    ConsoleDisplayMode(RequiredActionContext requiredActionContext) {
        this.requiredActionContext = requiredActionContext;
    }

    ConsoleDisplayMode(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 ConsoleDisplayMode challenge() {
                return ConsoleDisplayMode.this;
            }

            public LoginFormsProvider form() {
                return ConsoleDisplayMode.this.form();
            }

            public Response message(String msg, String... params) {
                return ConsoleDisplayMode.this.message(msg, params);
            }

            public Response text(String text) {
                return ConsoleDisplayMode.this.text(text);

            }

            public ParamBuilder param(String name) {
                return HeaderBuilder.this.param(name);
            }
        }
    }
}