1. 程式人生 > 其它 >Spring Security + OAuth2.0 構建微服務統一認證解決方案(一)

Spring Security + OAuth2.0 構建微服務統一認證解決方案(一)

Spring Security + OAuth2.0 構建微服務統一認證解決方案

在做專案的過程中,發現在各個服務的大量介面中,都存在認證和鑑權的邏輯,出現了大量重複程式碼。
優化的目標是在微服務架構中,和認證鑑權相關的邏輯僅存在認證和閘道器兩個服務中,其他服務僅需關注自己的業務邏輯即可。

搭建過程可以分為以下幾步

  1. 構建簡單的Spring Security + OAuth2.0 認證服務
  2. 優化認證服務(使用JWT技術加強token,自定義auth介面以及返回結果)
  3. 配置gateway服務完成簡單鑑權功能
  4. 優化gateway配置(新增閘道器白名單等)

(一)構建簡單的Spring Security + OAuth2.0 認證服務

一. 建立maven子專案,引入相關依賴

這裡要注意的是專案使用的spring cloud 是2020.0.4版本,而在2020.0.0版本後,spring-cloud-starter-oauth2 被移除了,所以必須指定spring-cloud-starter-oauth2的版本號才可以匯入

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-starter-oauth2 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.1.RELEASE</version>
    </dependency>
</dependencies>

二. 建立 UserServiceImpl 類實現 UserDetailsService 介面,用於載入使用者資訊

這個 UserDetailsService 介面是 Spring Security 提供的,需要實現 loadUserByUsername(String username) 函式,返回使用者資訊(這個資料結構需要自定義。

實現它的目的是在認證的過程中會用到,簡單描述認證的過程:

  • 前端傳送認證請求,請求裡帶有username、password
  • Spring Security根據username,呼叫 loadUserByUsername 拿到使用者詳細資訊
  • 使用者詳細資訊裡包含password,對比判斷前端請求中帶的密碼引數是否正確,如果不正確不通過認證。
  • 使用者詳細資訊可以按需提供一些使用者狀態、判斷是否被凍結、是否被禁用等,來判斷是否通過認證。

所以我們需要先實現一個數據結構,這裡實現了Spring Security提供的UserDetails。

@Data
@Builder
public class SecurityUser implements UserDetails {
     
    // 這裡只是最基本的使用者欄位,後續可以新增欄位,設計複雜的許可權機制,配合下面的判別函式使用
    private int id;
    private String userName;
    private String password;
    private Boolean isEnabled;
    private Collection<SimpleGrantedAuthority> authorities;
 
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }
 
    @Override
    public String getPassword() {
        return this.password;
    }
 
    @Override
    public String getUsername() {
        return this.userName;
    }
     
    // 以下四個函式,都可以根據一些使用者欄位新增判別邏輯,非常靈活
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
 
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
 
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
 
    @Override
    public boolean isEnabled() {
        return this.isEnabled;
    }
}

然後實現 UserDetailsService 介面

@Service
public class UserServiceImpl implements UserDetailsService {
     
    // 這裡用自定義資料舉例,後續可通過資料庫獲取使用者資訊
    private static List<SecurityUser> mockUsers;
 
    static {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        // 這裡密碼必須加密
        String pwd = passwordEncoder.encode("yanch");
        mockUsers = new ArrayList<>();
        SecurityUser user = SecurityUser.builder()
                .userName("yanch")
                .password(pwd)
                .authorities(Arrays.asList(new SimpleGrantedAuthority("ADMIN")))
                .isEnabled(true)
                .build();
        mockUsers.add(user);
    }
 
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        Optional<SecurityUser> user = mockUsers.stream().filter(u -> u.getUsername().equals(userName)).findFirst();
        if (!user.isPresent()) {
            throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
        }
 
        SecurityUser securityUser = user.get();
        // 下面丟擲的異常 Spring Security 會自動捕獲並進行返回
        if (!securityUser.isEnabled()) {
            throw new DisabledException(MessageConstant.ACCOUNT_DISABLED);
        } else if (!securityUser.isAccountNonLocked()) {
            throw new LockedException(MessageConstant.ACCOUNT_LOCKED);
        } else if (!securityUser.isAccountNonExpired()) {
            throw new AccountExpiredException(MessageConstant.ACCOUNT_EXPIRED);
        } else if (!securityUser.isCredentialsNonExpired()) {
            throw new CredentialsExpiredException(MessageConstant.CREDENTIALS_EXPIRED);
        }
        return securityUser;
    }
}

三. 進行一些配置

配置spring security

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }
 
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        // spring security 5.0 之後預設實現類改為 DelegatingPasswordEncoder 此時密碼必須以加密形式儲存
        return new BCryptPasswordEncoder();
    }
}

新增認證服務的配置

@Configuration
// 通過該註解暴露OAuth的鑑權介面 /oauth/token 等
@EnableAuthorizationServer
public class OAuth2ServerConfig extends AuthorizationServerConfigurerAdapter {
 
    // 這裡的 AuthenticationManager 和 PasswordEncoder 都是在上面的 WebSecurityConfig 中配置過的
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private UserServiceImpl userService;
    @Autowired
    private PasswordEncoder passwordEncoder;
 
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 進行本條設定以後 引數可以在form-data設定,而不必要在Authorization設定了
        security.allowFormAuthenticationForClients();
    }
 
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 通過client_id可以區分不同客戶端,可用於後續的自定義鑑權
                .withClient("portal")
                // 密碼必須加密
                .secret(passwordEncoder.encode("123456"))
                .authorizedGrantTypes("password", "refresh_token")
                .scopes("webclient")
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(3600*5);
    }
 
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                // 配置獲取使用者資訊
                .userDetailsService(userService);
    }
}

四. 簡單測試

服務啟動,可以看到我們想要的埠已經暴露出來了

postman測試結果如下(body裡設定form-data和 RequestParam效果是一樣的)
獲取Token:

重新整理Token:

五. 可能遇到的問題

1) /oauth/token 介面 403
可能是在配置的時候沒加 @EnableAuthorizationServer 註解

2)/oauth/token 介面 401

可能是未進行如下配置,導致client_id和client_secret不可以在form-data裡提交

如果執意不進行配置,在postman裡就需要顯式設定鑑權方式,這樣也可以完成認證,如下圖。

3) 介面返回 invalid_grant
可能是沒有對密碼進行加密,導致驗證失敗