2025/04/21

SpringBoot3 Web 3

CORS 跨來源資源共享

SAMEORIGIN 同源政策,是 Netscape 提出,所有支援 Javascript 的瀏覽器都使用這個策略,同源是 domain name, port, protocol 相同的意思,網頁執行一個 script 時,會檢查是否同源,非同源就會拒絕。

只要 domain name, port, protocol 某一個不同,就代表 cross origin。

CORS 允許伺服器指示瀏覽器允許從除其自身以外的任何來源載入資源。

spring MVC 本身透過在 method 上設定 @CrossOrigin

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") @Size(min = 5, max = 8) String userId) {
        User user = new User("test1", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }
}

@CrossOrigin 預設支援所有 request 來源、所有 http header, methods

@CrossOrigin 也可以用在 class

@Slf4j
@CrossOrigin(origins = "https://www.test.com", maxAge=3600)
@RestController
public class ResponseBodyController {
}

也可以放在 WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/user/**")
                .allowedMethods("GET", "POST")
                .allowedOrigins("https://www.test.com")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

安全性

加上 security library

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

啟動時會出現以下的 log

2024-11-20T16:36:50.740+08:00  WARN 5307 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 06ee988c-9983-4187-b040-5bfadde83893

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2024-11-20T16:36:50.784+08:00  INFO 5307 --- [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager

瀏覽 http://localhost:8080/demo1/test 時,會出現 ## Please sign in 的登入畫面

這時候填寫

username: user
password: 06ee988c-9983-4187-b040-5bfadde83893

就可以打開 index 頁面

可在 application.yml 修改預設的帳號密碼

spring:
  security:
    user:
      name: root
      password: password

自訂安全機制

可註冊 SecurityFilterChain 與 UserDetailsService 類別,實作自訂安全機制

SecurityConfig.java

package tw.com.test.demo1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests((authorize) -> {
            authorize.requestMatchers("/test/**").hasRole("TEST").requestMatchers("/**").permitAll();
        }).logout((logout) -> logout.logoutUrl("/")).formLogin(withDefaults()).build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("test").password("{noop}test").roles("ADMIN", "TEST").build());
        manager.createUser(User.withUsername("root").password("{noop}root").roles("ADMIN").build());
        return manager;
    }

}

REST service

實作 REST service 有兩種方式

  • RestTemplate (servlet)

  • WebClient (reactive)

RestTemplate (servlet)

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

自動設定類別 RestTemplateAutoConfiguration

註冊在 org.springframework.boot.autoconfigure.AutoConfiguration.imports

ResTemplate 並沒有任何預設的註冊

必須要先註冊一個 RestTemplate instance,可透過 restTemplateBuilder 產生

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .build();
    }
}

新增一個 controller 測試

package tw.com.test.demo1.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import tw.com.test.demo1.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/demo1/user/json/{uid}";
    private final RestTemplate restTemplate;

    @GetMapping("/restTemplate/{uid}")
    public User restTemplate(@PathVariable("uid") String uid) {
        return this.restTemplate.getForObject(GET_USERINFO_URL, User.class, uid);
    }

}

用 postman 測試 GET http://localhost:8080/demo1/restTemplate/10000

回傳結果

{
    "id": 10000,
    "age": 18,
    "username": "test1"
}

自訂 RestTemplate,加上 timeout, basic authentication

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(5))
                .basicAuthentication("test", "test")
                .build();
    }

也可以直接實作 RestTemplateCustomizer

package tw.com.test.demo1.handler;

import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.routing.HttpRoutePlanner;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;


@Slf4j
@Component
public class CustomRestTemplateCustomizer implements RestTemplateCustomizer {

    @Override
    public void customize(RestTemplate restTemplate) {
        HttpRoutePlanner routePlanner = new CustomRoutePlanner(new HttpHost("proxy.test.com"));
        RequestConfig requestConfig = RequestConfig.custom().build();
        HttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).setRoutePlanner(routePlanner).build();
        restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient));
    }

    static class CustomRoutePlanner extends DefaultProxyRoutePlanner {

        CustomRoutePlanner(HttpHost proxy) {
            super(proxy);
        }

        @Override
        protected HttpHost determineProxy(HttpHost target, HttpContext context) throws HttpException {
            log.info("hostName is {}", target.getHostName());
            if ("localhost".equals(target.getHostName())) {
                return null;
            }
            return super.determineProxy(target, context);
        }

    }

}

有用到 http client 5

        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
        </dependency>

也可以實作最底層的 HttpAccessor


WebClient (reactive)

WebClient 是使用 speign WebFlux 的 http client

pom.xml

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

User.java

package tw.com.test.demo3.bean;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
    private Integer age;
}

WebConfig.java

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        return webClientBuilder.build();
    }

}

CallRestController.java

package tw.com.test.demo3.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/user/json/{uid}";

    private final WebClient webClient;

    @GetMapping("/webClient/{uid}")
    public Mono<User> webClient(@PathVariable("uid") String uid) {
        return this.webClient.get().uri(GET_USERINFO_URL, uid).retrieve().bodyToMono(User.class);
    }

}

這是要呼叫的 rest url

package tw.com.test.demo3.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") String userId) {
        User user = new User((long)1000, "test", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }

}

以 GET method 呼叫 http://localhost:8080/webClient/10000

就能取得結果

{
    "id": 10000,
    "username": "test",
    "age": 18
}

自訂 WebClient

修改 WebConfig.java,改用 reactor netty 的 ReactorClientHttpConnector

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

//    @Bean
//    public WebClient webClient(WebClient.Builder webClientBuilder) {
//        return webClientBuilder.build();
//    }

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        HttpClient httpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3).doOnConnected(conn -> {
            conn.addHandlerLast(new ReadTimeoutHandler(3000));
            conn.addHandlerLast(new WriteTimeoutHandler(3000));
        });
        ReactorClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
        return webClientBuilder.clientConnector(connector).build();
    }

}

可改用 ClientHttpConnector 的其他實作的客戶端,目前 spring boot 支援

  • Reactor netty (default)

  • Jetty RS Client

  • Apache HttpClient

  • JDK HttpClient

沒有留言:

張貼留言