1

I have configured additional port to work on https for an REST endpoint. However, while sending request to this endpoint, application does not negotiate over https instead it uses only http. Framework is on Spring Boot 2.7.5.

@Configuration 
public class PublicPortConfig {
private static final Logger logger = LogManager.getLogger(PublicPortConfig.class);

@Value("${server.port.public:19280}")
private int port;

@Bean
@ConditionalOnProperty(name = "server.port.public")
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> publicServletContainerCustomizer (
        ServerProperties serverProperties) {
    logger.info("Configuring public port: {}", port);
    return tomcat -> {          
        Connector connector = new Connector();

        connector.setPort(port);
        connector.setScheme("https");
                    
        SSLHostConfig sslHostConfig = new SSLHostConfig();
        
               sslHostConfig.setCertificateKeystoreFile(serverProperties.getSsl().getKeyStore());
        sslHostConfig.setCertificateKeyPassword(serverProperties.getSsl().getKeyStorePassword());
        sslHostConfig.setCertificateKeystoreType(serverProperties.getSsl().getKeyStoreType());

        connector.addSslHostConfig(sslHostConfig);
        
        tomcat.addAdditionalTomcatConnectors(connector);
    };
}
}
@RestController
public class ExternalApiController {
    @GetMapping("/api/public")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("Hello Customer (public)");
    }
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    logger.info("Configuring HttpSecurity via SecurityFilterChain...");
    // @formatter:off
    http.authorizeRequests()
            .antMatchers(AUTH_PATH_WHITELIST).permitAll()
            .antMatchers("/api/public/**").permitAll()
            .antMatchers("/api/admin/**").hasAnyRole("ADMIN")
            .antMatchers("/actuator/**").hasAnyRole("ADMIN", "ACTUATOR")
            .and()
                .authorizeRequests().anyRequest().fullyAuthenticated();
    http.sessionManagement()
             .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
             .maximumSessions(MAX_SESSION_ACTIVE);
    http.httpBasic()
        .realmName("API Auth via DB or LDAP")
        .and()
        .authenticationProvider(customAuthenticationProvider);
    http.csrf().csrfTokenRepository(cookieCsrfTokenRepository());
    /**
     * Force Disable for API
     */
    http.csrf().disable();
    // http.cors().disable();
    // @formatter:on

    return http.build();
}

ExternalApiController sends response when request is sent on http and not on https. All fully authenticated endpoints works perfectly well on https.

cURL gives following results

$ curl --insecure -H "Accept: application/json" -H "Content-Type: application/json" -X GET "https://localhost:19280/app/api/public"

Response: curl: (35) LibreSSL/3.3.6: error:1404B42E:SSL routines:ST_CONNECT:tlsv1 alert protocol version Java Backend Error: java.lang.IllegalArgumentException: Invalid character found in method name [0x160x030x010x01(0x010x000x01$0x030x030\90xe9}0xef0x980x970x1b+0x820x0f0xc10xbc0xde0x8370xfceI0xdey0x100x1f0x99vJ0xa50x7f_0xd9Y ]. HTTP method names must be tokens

Where as http request using cURL

$ curl --insecure -H "Accept: application/json" -H "Content-Type: application/json" -X GET "http://localhost:19280/app/api/public"

Response: Hello Customer (public)

Application is started on 2 ports as per below log INFO 2023-06-06/17:08:18/GMT+05:30 [app-rest-api] (TomcatWebServer.java:220) (start) Tomcat started on port(s): 9280 (https) 19280 (https) with context path '/app'

Port 9280 is ok. However, anything I access on 19280 throws error

Appreciate any help. Thanks in advance

Anand
  • 1,845
  • 2
  • 20
  • 25
  • Any reason for tweaking WebServerFactoryCustomizer Bean ? Also what do you mean by additional port ? – Jay Yadav Jun 06 '23 at 11:08
  • Actually my objective was to enable private and public api where public api will only be accessed over internet which will be enabled in firewall. – Anand Jun 06 '23 at 11:48
  • it is something to do with SSL as I can see (1) port Starting ProtocolHandler ["https-jsse-nio-9280"] (2) port Starting ProtocolHandler ["http-nio-19280"] though it shows Tomcat started on port(s): 9280 (https) 19280 (https). Http nio is for http and not https. Any lead will be off great help – Anand Jun 07 '23 at 08:55
  • Let me give it a try & see – Jay Yadav Jun 07 '23 at 16:03
  • I got the thing working, i will cleanup and post complete solution tomorrow. – Anand Jun 07 '23 at 17:32
  • Excellent !! will wait for the answer – Jay Yadav Jun 07 '23 at 18:09

2 Answers2

2

Couple of months back I enabled https for our REST API in Spring boot,used self signed certificate for local testing generated using Java keytool

keytool -genkeypair -alias team_api -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore gaskit.p12 -storepass pwd -validity 3650

And then adding following properties in application.properties

server.ssl.key-store-type=${ssl_key_store_type}
# The path to the keystore containing the certificate
server.ssl.key-store=${ssl_key_store}
# The password used to generate the certificate
server.ssl.key-store-password=${ssl_key_store_Password}
# The alias mapped to the certificate
server.ssl.key-alias=${ssl_key_alias}
server.ssl.enabled=true

Jay Yadav
  • 236
  • 1
  • 2
  • 10
  • Thanks Jay, I am using Self Signed SSL certificate on the local port which is working. However, having additional https port is not working using the either same or different certificate – Anand Jun 06 '23 at 11:24
  • 1
    INFO 2023-06-06/17:08:18/GMT+05:30 [app-rest-api] (TomcatWebServer.java:220) (start) Tomcat started on port(s): 9280 (https) 19280 (https) with context path '/app' As you can see rest api is available on 2 ports. However, anything I access on 19280 throws error. – Anand Jun 06 '23 at 11:42
  • Ahh I see there are 2 Bean of Tomcat in your application context, Listening to respective ports !! But basically in real world we redirect traffic on http to https using load balancer, your application should only support https protocol. – Jay Yadav Jun 06 '23 at 12:43
  • Maybe possible SSL certificate is not correctly configured on your other port, can you try once by only enabling https and using same certificate, just to check if certificate is not causing this issue. Bcoz the error you posted occurs then only https://stackoverflow.com/a/41728777/16769477 – Jay Yadav Jun 06 '23 at 12:51
  • SSL Cert is working fine and I able access all endpoints /api/private on https (primary 9280). Only additional port 19280 which is intended for public api end point is not working. – Anand Jun 07 '23 at 03:08
1

Finally, I am able to get Public / Private Api EndPoints by deep dive into Http11NioProtocol. Thanks to Spring Community Documents Spring Docs under Section: Enable Multiple Connectors with Tomcat for all explanations.

Please find complete solution below

  1. Create Configuration File for Public Api

    
    @Configuration
    @PropertySource("classpath:public-api.properties")
    @ConfigurationProperties(prefix = "public.ssl")
    @Data
    public class PublicApiConfig {
    private int port;
    private String keyStoreType;
    private String keyStore;
    private String keyStorePassword;
    private String keyAlias;
    private List pathsToMatch;
    }
    
  2. Configuration File public-api.properties

    
    public.ssl.port=19280
    public.ssl.key-store-type=PKCS12
    public.ssl.key-store=ssl/your_certificate.p12
    public.ssl.key-store-password=your_password
    public.ssl.key-alias=tomcat
    public.ssl.paths-to-match=/api/public
    
  3. Define server.public.ssl.port.enabled in your application.properties and value could be true (Public Access) or false.

# true for Public Api Access
server.public.ssl.port.enabled=true
  1. Create PublicPortCustomizer class
@Component
@ConditionalOnProperty(value = "server.public.ssl.port.enabled", havingValue = "true", matchIfMissing = false)
public class PublicPortCustomizer {

    private static final Logger logger = LogManager.getLogger(PublicPortCustomizer.class);

    @Autowired
    PublicApiConfig publicPortConfig;

    /**
     * Verify if KeyStore is Valid
     */
    private boolean isValidKeyStoreFile(String keyStoreFile, String keyStorePassword, String keyStoreType) {
        try {
            File file = new File(keyStoreFile);
            KeyStore keystore = KeyStore.getInstance(keyStoreType);
            FileInputStream fis = new FileInputStream(file);
            keystore.load(fis, keyStorePassword.toCharArray());
            return true;
        } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        if (isValidKeyStoreFile(publicPortConfig.getKeyStore(), publicPortConfig.getKeyStorePassword(),
                publicPortConfig.getKeyStoreType())) {
            logger.info("Configuring Ssl Public for KeyStore: {}", publicPortConfig.getKeyStore());
            /**
             * Create Connector
             */
            Connector connector = createSslConnector();
            tomcat.addAdditionalTomcatConnectors(connector);
        } else {
            logger.fatal("KeyStore for Public: {} is INVALID", publicPortConfig.getKeyStore());
        }
        return tomcat;
    }

    /**
     * Create Ssl Connector
     * @return
     */
    private Connector createSslConnector() {
        logger.info("Configuring Additional Ssl Connector...");

        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");

        connector.setScheme("https");
        connector.setSecure(true);
        connector.setPort(publicPortConfig.getPort());

        Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();

        protocol.setSSLEnabled(true);
        protocol.setKeystoreFile(publicPortConfig.getKeyStore());
        protocol.setKeystorePass(publicPortConfig.getKeyStorePassword());
        protocol.setKeyAlias(publicPortConfig.getKeyAlias());

        return connector;
    }
}
  1. Define SpringSecurity where Public Api will be accessed through JWT which is controlled in Controller
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SpringSecurityConfig {

    @Autowired
    CustomAuthenticationProvider customAuthenticationProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .and()
                    .authorizeRequests().anyRequest().fullyAuthenticated();
        http.sessionManagement()
                 .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        http.httpBasic()
            .and()
            .authenticationProvider(customAuthenticationProvider);
        http.csrf().disable();
        http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
        return http.build();
    }
    
    @Bean
    @Lazy
    public FilterRegistrationBean publicEndpointsFilter() {
        return new FilterRegistrationBean(new PublicEndpointsFilter(publicPortConfig.getPort(),
                publicPortConfig.getPathsToMatch()));
    }
}
 

5a) Filters to handle public endpoints.

public class PublicEndpointsFilter implements Filter {

    private static final Logger logger = LogManager.getLogger(PublicEndpointsFilter.class);

    int publicPort;
    List pathsToMatch;

    /**
     * Default Constructor
     * 
     * @param publicPort
     * @param pathsToMatch
     */
    public PublicEndpointsFilter(int publicPort, List pathsToMatch) {
        this.publicPort = publicPort;
        this.pathsToMatch = pathsToMatch;
        logger.info("Configuring Filter for publicPort: {} pathsToMatch: {}", publicPort, pathsToMatch);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        boolean trustedEndPoint = isRequestForTrustedEndpoint(servletRequest);
        int status = HttpServletResponse.SC_OK;
        ///////////////////////
        if (servletRequest.getLocalPort() == publicPort && !trustedEndPoint) {
            status = HttpServletResponse.SC_FORBIDDEN;
        }
        if (status != HttpServletResponse.SC_OK) {
            logger.warn("Denying request on path: {} publicPort: {} localPort: {} status: {}", request.getRequestURI(),
                    publicPort, servletRequest.getLocalPort(), status);
            response.setStatus(status);
            servletResponse.getOutputStream().close();
            return;
        }
        ///////////////////////
        if (servletRequest.getLocalPort() != publicPort && trustedEndPoint) {
            status = HttpServletResponse.SC_FORBIDDEN;
        }
        if (status != HttpServletResponse.SC_OK) {
            logger.warn("Denying request on path: {} publicPort: {} localPort: {} status: {}", request.getRequestURI(),
                    publicPort, servletRequest.getLocalPort(), status);
            response.setStatus(status);
            servletResponse.getOutputStream().close();
            return;
        }
        logger.debug("Filter Delegate on path: {} localPort: {} status: {}", request.getRequestURI(),
                request.getLocalPort(), status);
        filterChain.doFilter(servletRequest, response);
    }

    /**
     * Verify Path as required for Public Access
     * 
     * @param servletRequest
     * @return
     */
    private boolean isRequestForTrustedEndpoint(ServletRequest servletRequest) {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        boolean ok = false;
        
        for (String pathToMatch : pathsToMatch) {
            String requestPath = request.getContextPath() + pathToMatch;
            ok = request.getRequestURI().startsWith(requestPath);
            logger.debug("Verified path: {} localPort: {}", requestPath, servletRequest.getLocalPort());
        }
        return ok;
    }
  1. Sample Public / Private Rest Controller
@RestController
public class PublicApiHelloController {
    @GetMapping("/api/public")
    public ResponseEntity hello() {
        return ResponseEntity.ok("Hello Customer (public)");
    }
}
@RestController
public class PrivateApiHelloController {
    // Requires Authentication
    @GetMapping("/api/private")
    public ResponseEntity hello() {
        return ResponseEntity.ok("Hello Staff (private)");
    }
}
  1. Start Application and you should see
INFO  2023-06-08/08:19:27/GMT+05:30 [app-api] (DirectJDKLog.java:173) (log) Starting ProtocolHandler ["https-jsse-nio-9280"]
INFO  2023-06-08/08:19:27/GMT+05:30 [app-api] (DirectJDKLog.java:173) (log) Starting ProtocolHandler ["https-jsse-nio-19280"]
INFO  2023-06-08/08:19:27/GMT+05:30 [app-api] (TomcatWebServer.java:220) (start) Tomcat started on port(s): 9280 (https) 19280 (https) with context path '/app'

Make sure ProtocolHandler starts with https-jsse-nio for all https enabled.

  1. Public Api Request
curl --insecure -H 'Accept: application/json' -H 'Content-Type: application/json' -X GET https://localhost:19280/app/api/public

Response:: Hello Customer (public)

9)Private Api Request

curl --insecure -H 'Accept: application/json' -H 'Content-Type: application/json' -X GET https://localhost:9280/app/api/private

Response:: {"timestamp":1686133851868,"status":401,"error":"Unauthorized","message":"Unauthorized","path":"/app/api/private"}

Unauthorized as private expects Authentication

  1. Public Ports are configured in firewall to allow traffic

Thanks

Anand
  • 1,845
  • 2
  • 20
  • 25