AuthProperties.java

/*
 * Copyright 2020 the original author or authors.
 *
 * 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.bremersee.security.authentication;

import java.io.Serializable;
import java.security.Principal;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.bremersee.security.FrameOptionsMode;
import org.bremersee.security.core.AuthorityConstants;
import org.bremersee.web.CorsProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;

/**
 * Authentication and authorization properties.
 *
 * @author Christian Bremer
 */
@ConfigurationProperties(prefix = "bremersee.auth")
@Getter
@Setter
@ToString(exclude = {"roleDefinitions", "ipDefinitions"})
@EqualsAndHashCode
@Validated
public class AuthProperties {

  /**
   * Specifies the behaviour of the resource server security auto configuration.
   */
  @NotNull
  private AutoSecurityMode resourceServer = AutoSecurityMode.OTHER;

  /**
   * Specifies the {@code X-Frame-Options} value.
   */
  @NotNull
  private FrameOptionsMode frameOptionsMode = FrameOptionsMode.DENY;

  /**
   * The order of the resource server security auto configuration.
   */
  private int resourceServerOrder = 51;

  /**
   * The role prefix to add.
   */
  @NotNull
  private String rolePrefix = "ROLE_";

  /**
   * The json path in the JWT to the roles.
   */
  @NotEmpty
  private String rolesJsonPath = "$.realm_access.roles";

  /**
   * Specifies whether the roles value is a list (json array) or a simple string.
   */
  private boolean rolesValueList = true;

  private String preferredLanguageJsonPath = "$.preferred_language";

  private String preferredTimeZoneJsonPath = "$.preferred_time_zone";

  /**
   * The role value separator to use if the role value is a simple string.
   */
  @NotNull
  private String rolesValueSeparator = " ";

  /**
   * The json path in the JWT to the user name.
   */
  @NotEmpty
  private String nameJsonPath = "$.preferred_username";

  @Deprecated
  @NotNull
  private Map<String, List<String>> roleDefinitions = new LinkedHashMap<>();

  @Deprecated
  @NotNull
  private Map<String, List<String>> ipDefinitions = new LinkedHashMap<>();

  @NotNull
  private List<PathMatcherProperties> pathMatchers = new ArrayList<>();

  @NotNull
  private AccessMode anyAccessMode = AccessMode.AUTHENTICATED;

  /**
   * Properties for eureka endpoints.
   */
  @NotNull
  private EurekaAccessProperties eureka = new EurekaAccessProperties();

  /**
   * Properties of the jwt cache.
   */
  @NotNull
  private JwtCache jwtCache = new JwtCache();

  /**
   * The properties for the oauth2 password flow.
   */
  @NotNull
  private PasswordFlow passwordFlow = new PasswordFlow();

  /**
   * The properties for the client credentials flow.
   */
  @NotNull
  private ClientCredentialsFlow clientCredentialsFlow = new ClientCredentialsFlow();

  /**
   * A list of in-memory users, that can login with basic authentication for testing purposes.
   */
  @NotNull
  private List<SimpleUser> inMemoryUsers = new ArrayList<>();

  /**
   * Build user details from in memory users.
   *
   * @param passwordEncoder the password encoder
   * @return the user details
   */
  @NotNull
  public UserDetails[] buildBasicAuthUserDetails(@Nullable PasswordEncoder passwordEncoder) {
    final PasswordEncoder encoder = Optional.ofNullable(passwordEncoder)
        .orElseGet(PasswordEncoderFactories::createDelegatingPasswordEncoder);
    return getInMemoryUsers().stream().map(
            simpleUser -> User.builder()
                .username(simpleUser.getName())
                .password(simpleUser.getPassword())
                .authorities(simpleUser.buildAuthorities())
                .passwordEncoder(encoder::encode)
                .build())
        .toArray(UserDetails[]::new);
  }

  /**
   * Ensure role prefix.
   *
   * @param role the role
   * @return the role with prefix
   */
  @NotEmpty
  public String ensureRolePrefix(@NotEmpty String role) {
    final String prefix = rolePrefix.trim();
    return StringUtils.hasText(prefix) && role.startsWith(prefix) ? role : prefix + role;
  }

  /**
   * Prepare path matchers.
   *
   * @param corsProperties the cors properties
   * @return the prepared path matchers
   */
  @NotEmpty
  public List<PathMatcherProperties> preparePathMatchers(
      @NotNull CorsProperties corsProperties) {

    List<PathMatcherProperties> pathMatchers = new ArrayList<>(getPathMatchers());
    PathMatcherProperties corsMatcher = new PathMatcherProperties();
    corsMatcher.setHttpMethod(HttpMethod.OPTIONS.name());
    corsMatcher.setAccessMode(AccessMode.PERMIT_ALL);
    if (corsProperties.isEnable() && !pathMatchers.contains(corsMatcher)) {
      pathMatchers.add(0, corsMatcher);
    }
    PathMatcherProperties anyRequestMatcher = new PathMatcherProperties();
    anyRequestMatcher.setAccessMode(getAnyAccessMode());
    if (!pathMatchers.contains(anyRequestMatcher)) {
      pathMatchers.add(anyRequestMatcher);
    }
    return pathMatchers;
  }

  /**
   * The path matcher properties.
   */
  @Setter
  @EqualsAndHashCode(of = {"httpMethod", "antPattern"})
  @NoArgsConstructor
  @Validated
  public static class PathMatcherProperties {

    /**
     * The constant ALL_HTTP_METHODS.
     */
    public static final String ALL_HTTP_METHODS = "*";

    /**
     * The constant ANY_PATH.
     */
    public static final String ANY_PATH = "/**";

    @NotNull
    private String httpMethod = ALL_HTTP_METHODS;

    @NotNull
    private String antPattern = ANY_PATH;

    @Getter
    @NotNull
    private AccessMode accessMode = AccessMode.AUTHENTICATED;

    @Getter
    @NotNull
    private List<String> roles = new ArrayList<>();

    @Getter
    @NotNull
    private List<String> ipAddresses = new ArrayList<>();

    /**
     * Gets http method.
     *
     * @return the http method
     */
    @NotEmpty
    public String getHttpMethod() {
      if (StringUtils.hasText(httpMethod)
          && HttpMethod.resolve(httpMethod.toUpperCase()) != null) {
        return httpMethod.toUpperCase();
      }
      return ALL_HTTP_METHODS;
    }

    /**
     * Gets ant pattern.
     *
     * @return the ant pattern
     */
    @NotEmpty
    public String getAntPattern() {
      return StringUtils.hasText(antPattern) ? antPattern : ANY_PATH;
    }

    /**
     * Http method http method.
     *
     * @return the http method
     */
    @Nullable
    public HttpMethod httpMethod() {
      return HttpMethod.resolve(getHttpMethod());
    }

    /**
     * Access expression string.
     *
     * @param ensureRolePrefixFunction the ensure role prefix function
     * @return the string
     */
    @NotEmpty
    public String accessExpression(
        @Nullable Function<String, String> ensureRolePrefixFunction) {
      return AccessExpressionUtils.buildAccessExpression(this, ensureRolePrefixFunction);
    }

    /**
     * Returns valid roles.
     *
     * @param ensureRolePrefixFunction the ensure role prefix function
     * @return the valid roles
     */
    public Set<String> roles(@Nullable Function<String, String> ensureRolePrefixFunction) {
      return roles.stream()
          .filter(StringUtils::hasText)
          .map(role -> ensureRolePrefixFunction != null
              ? ensureRolePrefixFunction.apply(role)
              : role)
          .collect(Collectors.toSet());
    }

    @Override
    public String toString() {
      final String path;
      if (ALL_HTTP_METHODS.equals(getHttpMethod())) {
        path = getAntPattern();
      } else {
        path = getHttpMethod() + ": " + getAntPattern();
      }
      return path + " with access = " + accessExpression(null);
    }
  }

  /**
   * The eureka access properties.
   */
  @Getter
  @Setter
  @ToString(exclude = {"password"})
  @EqualsAndHashCode
  @NoArgsConstructor
  @Validated
  public static class EurekaAccessProperties {

    private String username;

    private String password;

    private String role = AuthorityConstants.EUREKA_ROLE_NAME;

    /**
     * The IP addresses which can access protected eureka endpoints without authentication.
     */
    @NotNull
    private List<String> ipAddresses = new ArrayList<>();

    /**
     * Returns the role.
     *
     * @param ensureRolePrefixFunction function to ensure role prefix
     * @return the role
     */
    @Nullable
    public String role(@Nullable Function<String, String> ensureRolePrefixFunction) {
      return Optional.ofNullable(ensureRolePrefixFunction)
          .map(func -> StringUtils.hasText(role) ? func.apply(role) : role)
          .orElse(role);
    }

    /**
     * Build access expression.
     *
     * @param ensureRolePrefixFunction function to ensure role prefix
     * @return the access expression
     */
    @NotEmpty
    public String buildAccessExpression(
        @Nullable Function<String, String> ensureRolePrefixFunction) {
      return AccessExpressionUtils.buildAccessExpression(this, ensureRolePrefixFunction);
    }

    /**
     * Build basic auth user details.
     *
     * @param passwordEncoder the password encoder
     * @param otherUserDetails the other user details
     * @return the user details
     */
    @NotNull
    public UserDetails[] buildBasicAuthUserDetails(
        @Nullable PasswordEncoder passwordEncoder,
        UserDetails... otherUserDetails) {

      if (!StringUtils.hasText(username)) {
        return Optional.ofNullable(otherUserDetails).orElse(new UserDetails[0]);
      }
      final PasswordEncoder encoder = passwordEncoder != null
          ? passwordEncoder
          : PasswordEncoderFactories.createDelegatingPasswordEncoder();
      final SimpleUser user = new SimpleUser();
      user.setName(username);
      user.setPassword(StringUtils.hasText(password) ? password : "");
      if (StringUtils.hasText(role)) {
        user.setAuthorities(Collections.singletonList(role));
      }
      final UserDetails userDetails = User.builder()
          .username(user.getName())
          .password(user.getPassword())
          .authorities(user.buildAuthorities())
          .passwordEncoder(encoder::encode)
          .build();
      List<UserDetails> users = new ArrayList<>();
      if (otherUserDetails != null) {
        users.addAll(Arrays.asList(otherUserDetails));
      }
      users.add(userDetails);
      return users.toArray(new UserDetails[0]);
    }
  }

  /**
   * The client credentials flow.
   */
  @Getter
  @Setter
  @ToString(exclude = {"clientSecret"})
  @EqualsAndHashCode(exclude = {"clientSecret"})
  public static class ClientCredentialsFlow implements ClientCredentialsFlowProperties {

    private String tokenEndpoint;

    private String clientId;

    private String clientSecret;
  }

  /**
   * OAuth2 password flow configuration properties.
   */
  @Getter
  @Setter
  @ToString(exclude = {"clientSecret", "password"})
  @EqualsAndHashCode(exclude = {"clientSecret", "password"})
  public static class PasswordFlow implements PasswordFlowProperties {

    private String tokenEndpoint;

    private String clientId;

    private String clientSecret;

    private String username;

    private String password;
  }

  /**
   * A simple user.
   */
  @Getter
  @Setter
  @ToString(exclude = "password")
  @EqualsAndHashCode(exclude = "password")
  @NoArgsConstructor
  @Validated
  public static class SimpleUser implements Serializable, Principal {

    private static final long serialVersionUID = 1L;

    /**
     * The user name of the user.
     */
    private String name;

    /**
     * The password of the user.
     */
    private String password;

    /**
     * The granted authorities of the user. The names of the authorities starts with {@code ROLE_}.
     */
    @NotNull
    private List<String> authorities = new ArrayList<>();

    /**
     * Build authorities.
     *
     * @return the authorities
     */
    String[] buildAuthorities() {
      if (authorities.isEmpty()) {
        return new String[]{
            AuthorityConstants.USER_ROLE_NAME
        };
      }
      return authorities.toArray(new String[0]);
    }
  }

  /**
   * The jwt cache properties.
   */
  @Getter
  @Setter
  @ToString
  @EqualsAndHashCode
  @NoArgsConstructor
  @Validated
  public static class JwtCache {

    @NotEmpty
    private String externalCacheName = AccessTokenCache.CACHE_NAME;

    /**
     * The expiration time threshold.
     */
    @NotNull
    private Duration expirationTimeThreshold = Duration.ofSeconds(20L);

    /**
     * The database or cache key prefix.
     */
    private String keyPrefix = "jwt:";

    /**
     * Add key prefix to the given key.
     *
     * @param givenKey the given key
     * @return the string
     */
    public String addKeyPrefix(String givenKey) {
      if (StringUtils.hasText(getKeyPrefix())
          && !givenKey.startsWith(getKeyPrefix())) {
        return getKeyPrefix() + givenKey;
      }
      return givenKey;
    }

  }

}