在做項目的過程中,發現在各個服務的大量接口中,都存在認證和鑒權的邏輯,出現了大量重復代碼。
優化的目標是在微服務架構中,和認證鑒權相關的邏輯僅存在認證和網關兩個服務中,其他服務僅需關注自己的業務邏輯即可。
搭建過程可以分為以下幾步
- 構建簡單的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 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
可能是沒有對密碼進行加密,導致驗證失敗