/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you 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.elasticsearch.example.realm;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;

/**
 * A custom {@link Realm} implementation that reads in users, passwords, and roles from the settings defined in the
 * elasticsearch configuration file. Please note, this method of storing authentication data is <b>not secure</b> and
 * is done as an example to demonstrate the workings of a realm in a simple manner.
 *
 * This custom realm also uses a different authentication scheme. The realm will extract a {@link UsernamePasswordToken}
 * that can be used for authentication, but does so in a non standard manner by retrieving the values from a header
 * in the request.
 */
public class CustomRealm extends Realm{

    /*
     * The type of the realm. This is defined as a static final variable to prevent typos
     */
    public static final String TYPE = "custom";

    public static final String USER_HEADER = "User";
    public static final String PW_HEADER = "Password";

    private final Map<String, InfoHolder> usersMap;

    /**
     * Constructor for the Realm. This constructor delegates to the super class to initialize the common aspects such
     * as the logger.
     * @param config the configuration specific to this realm
     */
    CustomRealm(RealmConfig config) {
        super(TYPE, config);
        // load all user data into a map for easy access - NOT SECURE!
        this.usersMap = parseUsersMap(config.settings());
    }

    /**
     * This constructor should be used by extending classes so that they can specify their own specific type
     * @param type the type of the realm
     * @param config the configuration specific to this realm
     */
    CustomRealm(String type, RealmConfig config) {
        super(TYPE, config);
        // load all user data into a map for easy access - NOT SECURE!
        this.usersMap = parseUsersMap(config.settings());
    }

    /**
     * Indicates whether this realm supports the given token. This realm only support {@link UsernamePasswordToken} objects
     * for authentication
     * @param token the token to test for support
     * @return true if the token is supported. false otherwise
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof UsernamePasswordToken;
    }

    /**
     * This method will extract a token from the given {@link RestRequest} if possible. This implementation of token
     * extraction looks for two headers, the <code>User</code> header for the username and the <code>Password</code>
     * header for the plaintext password
     * @param threadContext the {@link ThreadContext} that contains headers and transient objects for a request
     * @return the {@link AuthenticationToken} if possible to extract or <code>null</code>
     */
    @Override
    public UsernamePasswordToken token(ThreadContext threadContext) {
        String user = threadContext.getHeader(USER_HEADER);
        if (user != null) {
            String password = threadContext.getHeader(PW_HEADER);
            if (password != null) {
                return new UsernamePasswordToken(user, new SecureString(password.toCharArray()));
            }
        }
        return null;
    }

    /**
     * Method that handles the actual authentication of the token. This method will only be called if the token is a
     * supported token. The method validates the credentials of the user and if they match, a {@link User} will be
     * returned as the argument to the {@code listener}'s {@link ActionListener#onResponse(Object)} method. Else
     * {@code null} is returned.
     * @param authenticationToken the token to authenticate
     * @param listener return authentication result by calling {@link ActionListener#onResponse(Object)}
     */
    @Override
    public void authenticate(AuthenticationToken authenticationToken, ActionListener<AuthenticationResult> listener) {
        try {
            UsernamePasswordToken token = (UsernamePasswordToken)authenticationToken;
            final String actualUser = token.principal();
            final InfoHolder info = usersMap.get(actualUser);

            if (info != null && token.credentials().equals(info.password)) {
                listener.onResponse(AuthenticationResult.success(new User(actualUser, info.roles)));
            }
            else {
                listener.onResponse(AuthenticationResult.notHandled());
            }
        } catch (Exception e) {
            listener.onFailure(e);
        }
    }

    /**
     * This method looks for a user that is identified by the given String. No authentication is performed by this method.
     * If this realm does not support user lookup, then this method will not be called.
     * @param username the identifier for the user
     * @param listener used to return lookup result
     */
    @Override
    public void lookupUser(String username, ActionListener<User> listener) {
        InfoHolder info = usersMap.get(username);
        if (info != null) {
            listener.onResponse(new User(username, info.roles));
        } else {
            listener.onResponse(null);
        }
    }

    /**
     * Utility method to extract a user from the realm's settings
     * @param settings the settings of the realm. This is not the node's settings
     * @return a {@link Map} of the usernames to the information about the user
     */
    private static Map<String, InfoHolder> parseUsersMap(Settings settings) {
        Map<String, Settings> usersSerttings = settings.getGroups("users");
        Map<String, InfoHolder> usersMap = new HashMap<>(usersSerttings.size());
        for (Entry<String, Settings> entry : usersSerttings.entrySet()) {
            Settings userSettings = entry.getValue();
            String username = entry.getKey();
            String password = userSettings.get("password");
            if (Strings.isEmpty(password)) {
                throw new IllegalArgumentException("password must be specified for user [" + username + "]");
            }
            usersMap.put(username, new InfoHolder(password, userSettings.getAsList("roles").toArray(new String[] {})));
        }
        return Collections.unmodifiableMap(usersMap);
    }

    /**
     * Class that holds the information about a user
     */
    private static class InfoHolder {
        private final String password;
        private final String[] roles;

        InfoHolder(String password, String[] roles) {
            this.password = password;
            this.roles = roles;
        }
    }
}