Spring Security + OAuth2.0 構建微服務統一認證解決方案(一)
在做專案的過程中,發現在各個服務的大量介面中,都存在認證和鑑權的邏輯,出現了大量重複程式碼。
優化的目標是在微服務架構中,和認證鑑權相關的邏輯僅存在認證和閘道器兩個服務中,其他服務僅需關注自己的業務邏輯即可。
搭建過程可以分為以下幾步
- 構建簡單的Spring Security + OAuth2.0 認證服務
- 優化認證服務(使用JWT技術加強token,自定義auth介面以及返回結果)
- 配置gateway服務完成簡單鑑權功能
- 優化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
可能是沒有對密碼進行加密,導致驗證失敗