001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2014-2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.doc;
018
019import static org.forgerock.guava.common.base.Strings.isNullOrEmpty;
020import org.forgerock.json.JsonValue;
021import org.glassfish.grizzly.http.Cookie;
022import org.glassfish.grizzly.http.Method;
023import org.glassfish.grizzly.http.server.HttpHandler;
024import org.glassfish.grizzly.http.server.HttpServer;
025import org.glassfish.grizzly.http.server.NetworkListener;
026import org.glassfish.grizzly.http.server.Request;
027import org.glassfish.grizzly.http.server.Response;
028import org.glassfish.grizzly.ssl.SSLContextConfigurator;
029import org.glassfish.grizzly.ssl.SSLEngineConfigurator;
030
031import java.io.ByteArrayOutputStream;
032import java.io.IOException;
033import java.io.InputStream;
034import java.util.ArrayList;
035import java.util.LinkedHashMap;
036import java.util.List;
037import java.util.Properties;
038import java.util.Scanner;
039import java.util.logging.Level;
040import java.util.logging.Logger;
041
042/**
043 * Simple servlet allowing user-agents to get a home page,
044 * and to post form-based login to access a protected profile page.
045 */
046public final class SampleServer {
047
048    private static final String EOL = System.getProperty("line.separator");
049    private static final Logger LOGGER = Logger.getLogger(SampleServer.class.getName());
050    private static final int DEFAULT_PORT = 8081;
051    private static final int DEFAULT_SSL_PORT = 8444;
052    /** Name of fake OpenAM cookie. */
053    private static final String IPLANET_DIRECTORY_PRO_COOKIE = "iPlanetDirectoryPro";
054    /** URL used to contact OpenAM. */
055    private static String openamUrl;
056
057    /**
058     * Start an HTTP server.
059     *
060     * @param args Optionally specify a free port number and free SSL port number.
061     *             Defaults: 8081 (HTTP), 8444 (HTTPS).
062     *             Also optionally specify a URL used to contact OpenAM.
063     *             Default: http://openam.example.com:8088/openam/oauth2
064     */
065    public static void main(String[] args) {
066        final String usage = "Optionally specify HTTP and HTTPS port numbers. "
067                + "Defaults: " + DEFAULT_PORT + ", " + DEFAULT_SSL_PORT + ".\n"
068                + "Also optionally specify a URL to contact OpenAM. "
069                + "Default: " + getOpenamUrl();
070        int port = DEFAULT_PORT;
071        int sslPort = DEFAULT_SSL_PORT;
072
073        if (args.length > 3) {
074            System.out.println(usage);
075            System.exit(-1);
076        }
077
078        if (args.length > 0) {
079            port = Integer.parseInt(args[0]);
080            if (args.length >= 2) {
081                sslPort = Integer.parseInt(args[1]);
082            }
083            if (args.length == 3) {
084                openamUrl = args[2];
085            }
086        }
087        // If the OpenAM URL is not set, set it now.
088        openamUrl = getOpenamUrl();
089
090        LOGGER.setLevel(Level.INFO);
091        runServer(port, sslPort);
092    }
093
094    /**
095     * Returns the URL used to contact OpenAM,
096     * setting it to the default if it is not already set.
097     * @return The URL used to contact OpenAM.
098     */
099    private static String getOpenamUrl() {
100        if (isNullOrEmpty(openamUrl)) {
101            return "http://openam.example.com:8088/openam/oauth2";
102        } else {
103            return openamUrl;
104        }
105    }
106
107    /**
108     * Run the HTTP server, listening on the chosen port.
109     * <p>
110     * On HTTP GET the server returns a home page with a login form.
111     * <p>
112     * On HTTP PUT with valid credentials, the server returns a profile page.
113     *
114     * @param port      Port on which the server listens for HTTP
115     * @param sslPort   Port on which the server listens for HTTPS
116     */
117    static void runServer(int port, int sslPort) {
118        start(port, sslPort, true);
119    }
120
121    /**
122     * Run the HTTP server, listening on the chosen port.
123     * <p>
124     * Use stop() to shut the server down.
125     *
126     * @param port Port on which the server listens for HTTP
127     * @return The HttpServer that is running if letRun is true
128     */
129    static HttpServer start(final int port, final int sslPort) {
130        return start(port, sslPort, false);
131    }
132
133    /**
134     * Run the HTTP server, listening on the chosen port.
135     *
136     * @param port          Port on which the server listens for HTTP
137     * @param sslPort       Port on which the server listens for HTTPS
138     * @param waitForCtrlC  If true, only stop the server when the user enters Ctrl+C
139     * @return The HttpServer that is running if letRun is true
140     */
141    static HttpServer start(final int port, final int sslPort, final boolean waitForCtrlC) {
142
143        final HttpServer httpServer = new HttpServer();
144        System.out.println("Preparing to listen for HTTP on port " + port + ".");
145        httpServer.addListener(new NetworkListener("HTTP", "0.0.0.0", port));
146        SSLEngineConfigurator sslEngineConfigurator = createSslConfiguration();
147        if (sslEngineConfigurator != null) {
148            System.out.println("Preparing to listen for HTTPS on port " + sslPort + ".");
149            System.out.println("The server will use a self-signed certificate not known to browsers.");
150            System.out.println("When using HTTPS with curl for example, try --insecure.");
151            httpServer.addListener(new NetworkListener("HTTPS", "0.0.0.0", sslPort));
152            httpServer.getListener("HTTPS").setSSLEngineConfig(sslEngineConfigurator);
153            httpServer.getListener("HTTPS").setSecure(true);
154        }
155        httpServer.getServerConfiguration().addHttpHandler(new SampleHandler());
156
157        System.out.println("Using OpenAM URL: " + getOpenamUrl() + ".");
158
159        if (waitForCtrlC) {
160            Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
161                @Override
162                public void run() {
163                    httpServer.shutdownNow();
164                }
165            }, "shutDownHook"));
166        }
167
168        try {
169            System.out.println("Starting server...");
170            httpServer.start();
171            if (waitForCtrlC) {
172                System.out.println("Press Ctrl+C to stop the server.");
173                Thread.currentThread().join();
174            }
175        } catch (Exception e) {
176            LOGGER.info(e.getMessage());
177        }
178
179        return httpServer;
180    }
181
182    /**
183     * Stop the HTTP Server started with waitForCtrlC set to false.
184     *
185     * @param httpServer The server to stop
186     */
187    static void stop(final HttpServer httpServer) {
188        httpServer.shutdownNow();
189    }
190
191    /**
192     * Returns an SSL configuration that uses {@code keystore.jks}.
193     *
194     * <br>
195     *
196     * The key store has one key pair with a self-signed cert, {@code test}.
197     * The key store was set up with the following commands:
198     *
199     * <pre>
200     * keytool \
201     *  -genkey \
202     *  -alias test \
203     *  -keyalg rsa \
204     *  -dname "cn=Doc Sample Server,dc=openig,dc=example,dc=com" \
205     *  -keystore keystore.jks \
206     *  -storepass changeit \
207     *  -keypass changeit
208     *
209     * keytool \
210     *  -selfcert \
211     *  -alias test \
212     *  -validity 7300 \
213     *  -keystore keystore.jks \
214     *  -storepass changeit \
215     *  -keypass changeit
216     * </pre>
217     *
218     * @return An SSL configuration that uses {@code keystore.jks},
219     *         or null if no valid SSL configuration could be set up.
220     */
221    private static SSLEngineConfigurator createSslConfiguration() {
222        SSLContextConfigurator sslContextConfigurator = new SSLContextConfigurator();
223        try {
224            // Cannot read the key store as a file inside a jar,
225            // so get the key store as a byte array.
226            sslContextConfigurator.setKeyStoreBytes(getKeyStore());
227            sslContextConfigurator.setKeyStorePass("changeit");
228            sslContextConfigurator.setKeyPass("changeit");
229        } catch (IOException e) {
230            LOGGER.info("Failed to load key store when setting up HTTPS.");
231            e.printStackTrace();
232            return null;
233        }
234
235        if (sslContextConfigurator.validateConfiguration(true)) {
236            SSLEngineConfigurator sslEngineConfigurator =
237                    new SSLEngineConfigurator(sslContextConfigurator.createSSLContext());
238            sslEngineConfigurator.setClientMode(false);
239            sslEngineConfigurator.setNeedClientAuth(false);
240            sslEngineConfigurator.setWantClientAuth(false);
241            return sslEngineConfigurator;
242        } else {
243            LOGGER.info("Failed to build a valid HTTPS configuration.");
244            return null;
245        }
246    }
247
248    /**
249     * Returns {@code keystore.jks} as a byte array.
250     * @return {@code keystore.jks} as a byte array.
251     * @throws IOException  Failed to read {@code keystore.jks}.
252     */
253    private static byte[] getKeyStore() throws IOException {
254        InputStream inputStream = SampleServer.class.getResourceAsStream("/keystore.jks");
255        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
256        int read;
257        byte[] data = new byte[4096];
258        while ((read = inputStream.read(data, 0, data.length)) != -1) {
259            buffer.write(data, 0, read);
260        }
261        return buffer.toByteArray();
262    }
263
264    /**
265     * Handler for HTTP GET and HTTP PUT requests.
266     */
267    static class SampleHandler extends HttpHandler {
268
269        @Override
270        public void service(Request request, Response response) throws Exception {
271            if (request.getHttpHandlerPath().equalsIgnoreCase("/login")) {
272                response.addCookie(new Cookie("login-cookie", "chocolate-chip"));
273            }
274
275            if (request.getHttpHandlerPath().startsWith("/uma")) {
276                // Provides access to UMA example client files.
277                // If there is a need to serve more files in the future,
278                // consider a CLStaticHttpHandler.
279                final String file = request.getHttpHandlerPath();
280                String content = SampleServer.getResourceAsString(file);
281                if (isNullOrEmpty(content)) {
282                    content = SampleServer.getResourceAsString("/uma/index.html");
283                }
284
285                if (file.endsWith("css")) {
286                    response.setContentType("text/css");
287                } else if (file.endsWith("js")) {
288                    response.setContentType("application/javascript");
289                } else {
290                    response.setContentType("text/html");
291                }
292
293                response.setStatus(200, "OK");
294                response.setContentLength(content.length());
295                response.getWriter().write(content);
296                return;
297            }
298
299            if (request.getHttpHandlerPath().equalsIgnoreCase("/.well-known/webfinger")) {
300                // See http://tools.ietf.org/html/rfc7033
301                // This is not fully compliant. Just enough for an OIDC discovery example.
302                response.setContentType("application/jrd+json");
303                response.setCharacterEncoding("UTF-8");
304
305                String subject = request.getParameter("resource");
306                if (isNullOrEmpty(subject)) {
307                    JsonValue error = new JsonValue(new LinkedHashMap<String, Object>());
308                    error.put("error", "Request must include a resource parameter.");
309                    response.setStatus(400, "Bad Request");
310                    response.setContentLength(error.toString().length());
311                    response.getWriter().write(error.toString());
312                    return;
313                }
314
315                LinkedHashMap<String, Object> links = new LinkedHashMap<>();
316                // Just enough for an OIDC discovery example.
317                links.put("rel", "http://openid.net/specs/connect/1.0/issuer");
318                links.put("href", getOpenamUrl());
319                List<LinkedHashMap<String, Object>> list = new ArrayList<>();
320                list.add(links);
321                JsonValue json = new JsonValue(new LinkedHashMap<String, Object>());
322                json.put("subject", subject);
323                json.put("links", list);
324
325                response.setStatus(200, "OK");
326                response.setHeader("Access-Control-Allow-Origin", "*");
327                response.setContentLength(json.toString().length());
328                response.getWriter().write(json.toString());
329                return;
330            }
331
332            // When a fake OpenAM cookie is presented, simulate OpenAM's response.
333            // If the cookie smells real, continue processing.
334            for (Cookie cookie : request.getCookies()) {
335                if (cookie.getName().equalsIgnoreCase(IPLANET_DIRECTORY_PRO_COOKIE)) {
336                    String[] credentials = cookie.getValue().split(":");
337                    if (credentials.length == 2) {
338                        simulateOpenAMResponse(credentials[0], credentials[1], response);
339                        return;
340                    }
341                }
342            }
343
344            if (Method.GET == request.getMethod()) {
345                String homePage = getResourceAsString("/home.html");
346
347                response.setContentType("text/html");
348                response.setStatus(200, "OK");
349                response.setContentLength(homePage.length());
350                response.getWriter().write(homePage);
351            }
352
353            if (Method.POST == request.getMethod()) {
354                String username;
355                String password;
356
357                // Allow use of IDToken1 (username) and IDToken2 (password)
358                // to simulate the behavior of the OpenAM classic UI login page.
359                username = request.getParameter("IDToken1");
360                password = request.getParameter("IDToken2");
361                if (username != null && password != null) {
362                    simulateOpenAMResponse(username, password, response);
363                    return;
364                }
365
366                // Accept username and password as headers for testing.
367                if (notNullOrEmpty(request.getHeader("username"))) {
368                    username = request.getHeader("username");
369                }
370                if (notNullOrEmpty(request.getHeader("password"))) {
371                    password = request.getHeader("password");
372                }
373
374                // Accept username and password as parameters
375                // in the query string or as form-encoded data.
376                if (notNullOrEmpty(request.getParameter("username"))) {
377                    username = request.getParameter("username");
378                }
379                if (notNullOrEmpty(request.getParameter("password"))) {
380                    password = request.getParameter("password");
381                }
382
383                if (username == null || password == null) {
384                    final String authRequired = "Authorization Required";
385                    response.setStatus(401, authRequired);
386                    response.setContentLength(authRequired.length() + EOL.length());
387                    response.getWriter().write(authRequired + EOL);
388                    return;
389                }
390
391                if (credentialsAreValid(username, password)) {
392
393                    // Replace profile page placeholders and respond.
394                    final StringBuilder headers = new StringBuilder();
395                    for (String name : request.getHeaderNames()) {
396                        for (String header : request.getHeaders(name)) {
397                            headers.append(name)
398                                    .append(": ")
399                                    .append(header)
400                                    .append("<br>");
401                        }
402                    }
403
404                    String profilePage = getResourceAsString("/profile.html")
405                            .replaceAll(EOL, "####")
406                            .replaceAll("USERNAME", username)
407                            .replace("METHOD", request.getMethod().getMethodString())
408                            .replace("REQUEST_URI", request.getDecodedRequestURI())
409                            .replace("HEADERS", headers.toString())
410                            .replaceAll("####", EOL);
411
412                    response.setContentType("text/html");
413                    response.setStatus(200, "OK");
414                    response.setContentLength(profilePage.length());
415                    response.getWriter().write(profilePage);
416
417                } else {
418                    final String forbidden = "Forbidden";
419                    response.setStatus(403, forbidden);
420                    response.setContentLength(forbidden.length() + EOL.length());
421                    response.getWriter().write(forbidden + EOL);
422                }
423            }
424        }
425
426        /**
427         * Returns true if the String to test is null nor empty.
428         * @param s The String to test.
429         * @return true if the String to test is null nor empty.
430         */
431        private static boolean isNullOrEmpty(final String s) {
432            return s == null || s.isEmpty();
433        }
434
435        /**
436         * Returns true if the String to test is neither null nor empty.
437         * @param s The String to test.
438         * @return true if the String to test is neither null nor empty.
439         */
440        private static boolean notNullOrEmpty(final String s) {
441            return s != null && !s.isEmpty();
442        }
443    }
444
445    /**
446     * Simulates a response from OpenAM.
447     *
448     * <br>
449     *
450     * If the username and password are valid, sets response status to 200 OK
451     * and writes a text message and fake SSO Token cookie in the response.
452     * The fake cookie holds the credentials.
453     *
454     * Otherwise, sets response status to 403 Forbidden
455     * and writes a failure text message in the response.
456     *
457     * @param username      A username such as {@code demo}
458     * @param password      A password such as {@code changeit}
459     * @param response      The response to the request
460     * @throws IOException  Failed when checking credentials
461     */
462    private static void simulateOpenAMResponse(final String username,
463                                               final String password,
464                                               final Response response) throws IOException {
465        String message;
466        if (credentialsAreValid(username, password)) {
467            message = "Welcome, " + username + "!" + EOL;
468            Cookie cookie = new Cookie(IPLANET_DIRECTORY_PRO_COOKIE, username + ":" + password);
469            cookie.setPath("/");
470            response.addCookie(cookie);
471            response.setStatus(200, "OK");
472        } else {
473            String user = isNullOrEmpty(username) ? "" : username + ", ";
474            message = "Too bad, " + user + "you failed to authenticate." + EOL;
475            response.setStatus(403, "Forbidden");
476        }
477        response.setContentType("text/plain");
478        response.setContentLength(message.length());
479        response.getWriter().write(message);
480    }
481
482    /**
483     * Check whether username and password credentials are valid.
484     *
485     * @param username A username such as {@code demo}
486     * @param password A password such as {@code changeit}
487     *
488     * @return True if the username matches the password in credentials.properties
489     * @throws IOException Could not read credentials.properties
490     */
491    static synchronized boolean credentialsAreValid(
492            final String username, final String password)
493            throws IOException {
494
495        if (isNullOrEmpty(username) || isNullOrEmpty(password)) {
496            return false;
497        }
498
499        boolean result = false;
500
501        Properties credentials = new Properties();
502        InputStream in = SampleHandler.class.getResourceAsStream("/credentials.properties");
503        credentials.load(in);
504
505        final String pwd = credentials.getProperty(username);
506        if (pwd != null) {
507            result = pwd.equals(password);
508        }
509
510        in.close();
511
512        return result;
513    }
514
515    /**
516     * Read the contents of a resource file into a string.
517     *
518     * @param resource Path to resource file
519     * @return String holding the content of the resource file
520     */
521    static synchronized String getResourceAsString(final String resource) {
522
523        StringBuilder content = new StringBuilder();
524        InputStream inputStream = SampleHandler.class.getResourceAsStream(resource);
525
526        Scanner scanner = null;
527        try {
528            scanner = new Scanner(inputStream);
529            while (scanner.hasNextLine()) {
530                content.append(scanner.nextLine()).append(EOL);
531            }
532        } finally {
533            if (scanner != null) {
534                scanner.close();
535            }
536        }
537
538        return content.toString();
539    }
540
541    /**
542     * Not used.
543     */
544    private SampleServer() {
545    }
546}