sping cloud gateway集成spring security實現前後端分離模式下的後端微服務認證授權

https://blog.csdn.net/MongolianWolf/article/details/94329980

文章目錄
引言
Spring Security
技術環境
集成步驟
高階用法
集成效果展示
參考資料
引言
  由於目前網上大部分spring security的集成都是基於傳統的spring servlet機制,而spring cloud gateway 採用webflux作爲底層web技術支持,不支持servlet,筆者在集成的過程中走了很多彎路,所以特地寫一篇spring cloud gateway和security的集成實踐博客,如有錯誤,歡迎指正。

Spring Security
  spring security 爲spring提供了一套web安全性的完整框架,主要包含用戶認證和用戶授權。在用戶認證方面,Spring Security 支持主流的驗證方式,包括HttpBasic、Http表單認證、Http摘要認證、OpenId(如Oauth)和LDAP。本文實現的功能是gateway網關集成security,前端利用form表單進行登陸認證後返回基於一個用戶名和密碼的加密串,後續前端調用其他接口需利用httpbasic攜帶加密串的方式進行認證和授權。

技術環境
 jdk 1.8
 spring-boot 2.1.4.RELEASE
 spring-cloud Greenwich.RELEASE
集成步驟
(1)創建spring boot工程,引入cloud gateway 和security 的jar包依賴,核心依賴包如圖:

注意:cloud gateway 不能和spring-web混合使用,cloud gateway採用的webflux技術,不能再引入spring-web包。

(2)編寫securtiy的核心認證授權配置
  如下,創建security的核心安全配置類SecurityConfig並自定義SecurityWebFilterChain,在webflux環境下要生效必須用註解@EnableWebFluxSecurity使其生效:

@EnableWebFluxSecurity
public class SecurityConfig {


    //security的鑑權排除的url列表
    private static final String[] excludedAuthPages = {
            "/auth/login",
            "/auth/logout",
            "/health",
            "/api/socket/**"
    };

    @Bean
    SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {
        http
                .authorizeExchange()
                .pathMatchers(excludedAuthPages).permitAll()  //無需進行權限過濾的請求路徑
                .pathMatchers(HttpMethod.OPTIONS).permitAll() //option 請求默認放行
                .anyExchange().authenticated()
                .and()
                .httpBasic()
                .and()
                .formLogin() //啓動頁面表單登陸,spring security 內置了一個登陸頁面/login
                .and().csrf().disable()//必須支持跨域
                .logout().disable();

        return http.build();
    }
}
  配置文件中添加以下security的用戶名和密碼,訪問受權限保護的頁面即會進入security的登陸認證頁面,只有輸入配置的用戶名和密碼後才能繼續訪問其他頁面。

#security 配置
spring.security.user.name=admin
spring.security.user.password=123456
  配置後,啓動spring boot 程序,輸入需授權的url,則會彈出以下頁面,用戶名密碼輸入登陸成功後即可正常訪問其他受保護頁面

注:此功能爲spring security 內置的formLogin默認基於用戶名和密碼的認證授權(表單登陸)功能,需開啓formLogin()功能。

高階用法
  現在項目開發都是前後端分離模式,對於前後端分離security的默認配置則不能滿足認證和授權的需求。下面講解前端通過form的login表單ajax提交給網關security的認證接口,認證成功後security在響應header中返回基於username:password的base64加密串token,後續前端再調用其他接口需基於http basci的安全機制進行授權(即在header中添加Authorization=basic token,spring security在收到請求後通過ServerHttpBasicAuthenticationConverter解析用戶認證信息,決定是否授權通過。
主要修改如下:
(1)自定義用戶認證邏輯

@Bean
SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {
    http
            .authorizeExchange()
            .pathMatchers(excludedAuthPages).permitAll()  //無需進行權限過濾的請求路徑
            .pathMatchers(HttpMethod.OPTIONS).permitAll() //option 請求默認放行
            .anyExchange().authenticated()
            .and()
            .httpBasic()
            .and()
            .formLogin().loginPage("/auth/login")
            .authenticationSuccessHandler(authenticationSuccessHandler) //認證成功
            .authenticationFailureHandler(authenticationFaillHandler) //登陸驗證失敗
            .and().exceptionHandling().authenticationEntryPoint(customHttpBasicServerAuthenticationEntryPoint)  //基於http的接口請求鑑權失敗
            .and() .csrf().disable()//必須支持跨域
            .logout().disable();

    return http.build();
}


@Bean
public PasswordEncoder passwordEncoder() {
    return  NoOpPasswordEncoder.getInstance(); //默認不加密
}
  security默認認證響應信息爲text/html,前後端分離一般返回json,此處自定義了認證成功和失敗的響應處理、鑑權失敗時的處理。
  認證成功處理器authenticationSuccessHandler,繼承security對gateway支持的認證成功處理器WebFilterChainServerAuthenticationSuccessHandler,並覆蓋其onAuthenticationSuccess方法,本例中認證成功在請求頭中返回Authorization(用戶名和密碼的base加密信息),代碼如下:

@Component
public class AuthenticationSuccessHandler extends WebFilterChainServerAuthenticationSuccessHandler   {

    @Override
    public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication){
        ServerWebExchange exchange = webFilterExchange.getExchange();
        ServerHttpResponse response = exchange.getResponse();
        //設置headers
        HttpHeaders httpHeaders = response.getHeaders();
        httpHeaders.add("Content-Type", "application/json; charset=UTF-8");
        httpHeaders.add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
        //設置body
        WsResponse wsResponse = WsResponse.success();
       byte[]   dataBytes={};
        ObjectMapper mapper = new ObjectMapper();
        try {
            User user=(User)authentication.getPrincipal();
            AuthUserDetails userDetails=buildUser(user);
            byte[] authorization=(userDetails.getUsername()+":"+userDetails.getPassword()).getBytes();
            String token= Base64.getEncoder().encodeToString(authorization);
            httpHeaders.add(HttpHeaders.AUTHORIZATION, token);
            wsResponse.setResult(userDetails);
            dataBytes=mapper.writeValueAsBytes(wsResponse);
        }
        catch (Exception ex){
            ex.printStackTrace();
            JsonObject result = new JsonObject();
            result.addProperty("status", MessageCode.COMMON_FAILURE.getCode());
            result.addProperty("message", "授權異常");
            dataBytes=result.toString().getBytes();
        }
        DataBuffer bodyDataBuffer = response.bufferFactory().wrap(dataBytes);
        return response.writeWith(Mono.just(bodyDataBuffer));
    }

    private AuthUserDetails  buildUser(User user){
        AuthUserDetails userDetails=new AuthUserDetails();
        userDetails.setUsername(user.getUsername());
        userDetails.setPassword(user.getPassword().substring(user.getPassword().lastIndexOf("}")+1,user.getPassword().length()));
        return userDetails;
    }
  其中AuthUserDetails 爲security維護的用戶信息接口UserDetails的自定義實現類,封裝了用戶賬戶和權限信息.

  認證失敗處理器authenticationFaillHandler,實現ServerAuthenticationFailureHandler並覆蓋其onAuthenticationFailure自定義認證失敗的處理邏輯,本例中僅返回認證失敗的響應信息:

@Component
public class AuthenticationFaillHandler  implements ServerAuthenticationFailureHandler {

    @Override
    public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException e) {
        ServerWebExchange exchange = webFilterExchange.getExchange();
        ServerHttpResponse response = exchange.getResponse();
        //設置headers
        HttpHeaders httpHeaders = response.getHeaders();
        httpHeaders.add("Content-Type", "application/json; charset=UTF-8");
        httpHeaders.add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
        //設置body
        WsResponse<String> wsResponse = WsResponse.failure(MessageCode.COMMON_AUTHORIZED_FAILURE);
        byte[]   dataBytes={};
        try {
            ObjectMapper mapper = new ObjectMapper();
            dataBytes=mapper.writeValueAsBytes(wsResponse);
        }
        catch (Exception ex){
            ex.printStackTrace();
        }
        DataBuffer bodyDataBuffer = response.bufferFactory().wrap(dataBytes);
        return response.writeWith(Mono.just(bodyDataBuffer));
    }
}
  認證成功後訪問新的接口需在請求頭中添加基於httpbasic的認證鑑權信息,服務端收到請求後通過識別爲httpbasic的鑑權信息,通過ServerHttpBasicAuthenticationConverter提取用戶名和密碼後進行鑑權,鑑權通過放行請求。
  此處自定義鑑權失敗時的處理邏輯CustomHttpBasicServerAuthenticationEntryPoint,只需繼承默認的httpbasic鑑權失敗處理器HttpBasicServerAuthenticationEntryPoint並覆蓋其commence方法即可:

@Component
public class CustomHttpBasicServerAuthenticationEntryPoint extends HttpBasicServerAuthenticationEntryPoint /* implements ServerAuthenticationEntryPoint */{


    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
    private static final String DEFAULT_REALM = "Realm";
    private static String WWW_AUTHENTICATE_FORMAT = "Basic realm=\"%s\"";
    private String headerValue = createHeaderValue("Realm");
    public CustomHttpBasicServerAuthenticationEntryPoint() {
    }

    public void setRealm(String realm) {
        this.headerValue = createHeaderValue(realm);
    }

    private static String createHeaderValue(String realm) {
        Assert.notNull(realm, "realm cannot be null");
        return String.format(WWW_AUTHENTICATE_FORMAT, new Object[]{realm});
    }

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            response.getHeaders().add("Content-Type", "application/json; charset=UTF-8");
            response.getHeaders().set(HttpHeaders.AUTHORIZATION, this.headerValue);
            JsonObject result = new JsonObject();
            result.addProperty("status", MessageCode.COMMON_AUTHORIZED_FAILURE.getCode());
            result.addProperty("message", MessageCode.COMMON_AUTHORIZED_FAILURE.getMsg());
            byte[] dataBytes=result.toString().getBytes();
            DataBuffer bodyDataBuffer = response.bufferFactory().wrap(dataBytes);
            return response.writeWith(Mono.just(bodyDataBuffer));
    }
}
  由於security在認證時必須採用一種密碼加密方式,在security5中默認的BCryptPasswordEncoder是隨機鹽的加密方式,且刪除了原有低版本的md5的encoder,所以此處需配置不加密模式,即NoOpPasswordEncoder,在後續用戶查找邏輯時可添加自定義的用戶密碼加密規則,只需和前端規則一致即可。

(2)定義用戶查找邏輯
  security 的認證和授權都離不開系統中的用戶,實際用戶都來自db,本例中採用的是系統配置的默認用戶。
  UserDetailsRepositoryReactiveAuthenticationManager作爲security的核心認證管理器,並調用userDetailsService去查找用戶,本集成環境中自定義用戶查找邏輯需實現ReactiveUserDetailsService接口並覆蓋findByUsername(通過用戶名查找用戶)方法,核心代碼如下:

@Component
public class SecurityUserDetailsService implements ReactiveUserDetailsService {

     @Value("${spring.security.user.name}")
     private   String userName;

    @Value("${spring.security.user.password}")
    private   String password;


    @Override
    public Mono<UserDetails> findByUsername(String username) {
       //todo 預留調用數據庫根據用戶名獲取用戶
        if(StringUtils.equals(userName,username)){
            UserDetails user = User.withUsername(userName)
                  .password(MD5Encoder.encode(password,username))
                    .roles("admin").authorities(AuthorityUtils.commaSeparatedStringToAuthorityList("admin"))
                    .build();
            return Mono.just(user);
        }
        else{
            return Mono.error(new UsernameNotFoundException("User Not Found"));

        }

    }

}
說明:爲避免密碼在系統中明文傳輸,前端傳入的密碼通過md5加鹽username的方式傳入後臺,所以security用戶查找邏輯也需要對配置的密碼做統一的處理,固此處加入了md5加密工具。
(3)其他擴展
  security 和webflux的集成核心是AuthenticationWebFilter 過濾器,可查看此過濾器關聯的內部接口自定義邏輯。
  httpbasic認證方式的核心配置在ServerHttpSecurity中HttpBasicSpec的configure方法

集成效果展示
1.用戶在前端輸入用戶名和加密後的密碼後以表單方式提交給formlogin認證接口:

可以看到認證成功後響應header中有Authorization信息:

2.訪問新的鑑權的接口只需在header中添加基於Authorization的httpbasic認證信息:

如果輸入錯誤的httpbasic 用戶認證信息:

項目源碼:
https://github.com/DarrenJiang1990/awesome-gateway-securtity

如有錯誤,歡迎指正和交流

參考資料
https://www.jb51.net/article/140429.htm
https://www.naturalprogrammer.com/blog/18149/reactive-spring-security-webflux-rest-web-services
https://www.sudoinit5.com/post/spring-reactive-auth-forms/#customized-webflux-form-authentication
https://blog.csdn.net/Dongguabai/article/details/80932225
https://docs.spring.io/spring-security/site/docs/current/reference/html5/#jc-webflux
spring security的用戶名密碼驗證規則: https://blog.csdn.net/qq924862077/article/details/83027033
https://github.com/eugenp/tutorials/tree/master/spring-5-reactive-security
————————————————
版權聲明:本文爲CSDN博主「武陵曉生」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/MongolianWolf/article/details/94329980

發佈了42 篇原創文章 · 獲贊 25 · 訪問量 24萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章