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 String 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()
                .id("001")
                .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:

五. 后續工作

上述的簡單框架中,token雖然可生成可刷新,但是它並沒有和用戶信息掛鈎,無法用於驗證。
故在此基礎上,可以進行的后續工作可以是:
(1)用redis做用戶信息緩存,驗證時通過token取redis緩存的用戶信息。

  • 優點:相對安全、支持較為復雜的鑒權邏輯
  • 缺點:數據庫性能成為瓶頸

(2)用JWT加強token,驗證時可以直接解析token獲取其中信息。

  • 優點:通用性強、易擴展、速度快
  • 缺點:數據安全性低、不適合存放大量信息、無法作廢未過期token

綜合考慮后,后續我們選用JWT加強Token

六. 可能遇到的問題

1) /oauth/token 接口 403
可能是在配置的時候沒加 @EnableAuthorizationServer 注解

2)/oauth/token 接口 401

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

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

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

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

github 倉庫


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM