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 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
可能是没有对密码进行加密,导致验证失败