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 仓库

posted @ 2021-11-11 15:21  BuptWade  阅读(2373)  评论(2编辑  收藏  举报