集成spring security oauth2 安全框架

560 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

auth模块 pom.xml引入依赖

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.2.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
  • UserDetails => Spring Security基础接口,包含某个用户的账号,密码,权限,状态(是否锁定)等信息。只有getter方法。
  • Authentication => 认证对象,认证开始时创建,认证成功后存储于SecurityContext
  • principal => 用户信息对象,是一个Object,通常可转为UserDetails

UserDetails接口

用于表示一个principal,但是一般情况下是作为(你所使用的用户数据库)和(Spring Security 的安全上下文需要保留的信息)之间的适配器。

实际上就是相当于定义一个规范,Security这个框架不管你的应用时怎么存储用户和权限信息的。只要你取出来的时候把它包装成一个UserDetails对象给我用就可以了。

@Data
@Builder
public class SecurityUser implements UserDetails, Principal {

    /** 统一默认为ROLE_USER */
    private static final String AUTHORITY = "ROLE_USER";

    private static final long serialVersionUID = 1443123970566148983L;
    /** 唯一主键id */
    private final String id;
    /** 密码 */
    private final String phone;

    private String username;

    private String password;
/*
    */
/** email *//*

    private final String email;
    */
/** 身份证号 *//*

    private final String idCardNo;
*/

    private Boolean isEnabled;

    private Collection<SimpleGrantedAuthority> 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 isEnabled;
    }

    @Override
    public String getName() {
        return this.getUsername();
    }
}

UserDetailsService

认证的操作,框架都已经帮你实现了,它所需要的只是,你给我提供获取信息的方式。所以它就定义一个接口,然后让你去实现,实现好了之后再注入给它。

框架提供一个UserDetailsService接口用来加载用户信息。如果要自定义实现的话,用户可以实现一个CustomUserDetailsService的类,

然后把你的应用中的UserService和AuthorityService注入到这个类中,用户获取用户信息和权限信息,

然后在loadUserByUsername方法中,构造一个User对象(框架的类)返回即可。

框架提供的UserDetailsService接口默认实现

InMemoryDaoImpl => 存储于内存

JdbcDaoImpl => 存储于数据库(磁盘)

其中,如果你的数据库设计符合JdbcDaoImpl中的规范,你也就不用自己去实现UserDetailsService了。

但是大多数情况是不符合的,因为你用户表不一定就叫users,可能还有其他前缀什么的,比如叫tb_users。或者字段名也跟它不一样。

如果你一定要使用这个JdbcDaoImpl,你可以通过它的setter方法修改它的数据库查询语句。 它是利用Spring框架的JdbcTemplate来查询数据库的

注入到认证处理类中的,框架利用AuthenticationManager(接口)来进行认证。而Security为了支持多种方式认证,它提供ProviderManager类,这个实现了AuthenticationManager接口。

它拥有多种认证方式,可以根据认证的类型委托给对应的认证处理类进行处理,这个处理类实现了AuthenticationProvider接口。

所以,最终UserDetailsService是注入到AuthenticationProvider的实现类中。

UserDetailService只单纯地负责存取用户信息,除了给框架内的其他组件提供数据外没有其他功能。而认证过程是由AuthenticationManager来完成的。

(大多数情况下,可以通过实现AuthenticationProvider接口来自定义认证过程)

@Service
public class SecurityUserServiceImpl implements UserDetailsService {

    /** 这里用自定义数据举例,后续可通过数据库获取用户信息 */
    private static final List<SecurityUser> mockUsers;

    static {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        // 这里密码必须加密
        String pwd = passwordEncoder.encode("asdf");
        mockUsers = new ArrayList<>();
        SecurityUser user = SecurityUser.builder()
                .id("001")
                .username("hy")
                .password(pwd)
                .authorities(List.of(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.isEmpty()) {
            throw new UsernameNotFoundException(BusinessCode.USERNAME_PASSWORD_ERROR.getMsg());
        }

        SecurityUser securityUser = user.get();
        // 下面抛出的异常 Spring Security 会自动捕获并进行返回
        if (!securityUser.isEnabled()) {
            throw new DisabledException(BusinessCode.ACCOUNT_DISABLED.getMsg());
        } else if (!securityUser.isAccountNonLocked()) {
            throw new LockedException(BusinessCode.ACCOUNT_LOCKED.getMsg());
        } else if (!securityUser.isAccountNonExpired()) {
            throw new AccountExpiredException(BusinessCode.ACCOUNT_EXPIRED.getMsg());
        } else if (!securityUser.isCredentialsNonExpired()) {
            throw new CredentialsExpiredException(BusinessCode.CREDENTIALS_EXPIRED.getMsg());
        }
        return securityUser;
    }
}

WebSecurityConfigurerAdapter

WebSecurityConfigurerAdapter是构建SecurityFilterChain的关键,在WebSecurityConfigurerAdapter的init方法中会创建一个SecurityBuilder类型的实例对象【HttpSecurity】并保存到WebSecurity的securityFilterChainBuilders属性中,后续通过SecurityBuilder来完成SecurityFilterChain的创建

URL强制拦截保护服务,可以配置哪些路径不需要保护,哪些需要保护。默认全都保护

\

  • 继承了WebSecurityConfigurerAdapter之后,再加上几行代码,我们就能实现以下的功能:
  • 1、要求用户在进入你的应用的任何URL之前都进行验证
  • 2、创建一个用户名是“user”,密码是“password”,角色是“ROLE_USER”的用户
  • 3、启用HTTP Basic和基于表单的验证
  • 4、Spring Security将会自动生成一个登陆页面和登出成功页面
  • @EnableWebSecurity注解以及WebSecurityConfigurerAdapter一起配合提供基于web的security。
  • 继承了WebSecurityConfigurerAdapter之后,再加上几行代码,我们就能实现以下的功能:
  • 1、要求用户在进入你的应用的任何URL之前都进行验证
  • 2、创建一个用户名是“user”,密码是“password”,角色是“ROLE_USER”的用户
  • 3、启用HTTP Basic和基于表单的验证
  • 4、Spring Security将会自动生成一个登陆页面和登出成功页面
  • 默认页面:
  • 登录页面:/login
  • 注销页面:/login?logout
  • 错误页面:/login?error
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 系统安全用户验证模式:
     * 1、使用内存模式创建验证
     * 2、使用数据库创建验证,实现userDetailsService接口即可
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }

    /**
     * 不定义没有password grant_type即密码授权模式
     * (总共四种授权模式:授权码、implicat精简模式、密码、client credentials)
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        // spring security 5.0 之后默认实现类改为 DelegatingPasswordEncoder 此时密码必须以加密形式存储
        return new BCryptPasswordEncoder();
    }

    /**
     * 如果有要忽略拦截校验的静态资源,在此处添加
     * 忽略任何以”/resources/”开头的请求,这和在XML配置http@security=none的效果一样
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/resources/**");

    }

}

AuthorizationServerConfigurerAdapter

AuthorizationServerConfigurerAdapter: 配置OAuth授权服务器的工作方式

使用spring Security OAuth2模块创建授权服务器,

需要使用注解@EnableAuthorizationServer并扩展AuthorizationServerConfigurerAdapter类

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    /**
     * EnableAuthorizationServer: 通过该注解暴露OAuth的鉴权接口 /oauth/token 等
     * 这里的 AuthenticationManager 和 PasswordEncoder 都是在上面的 WebSecurityConfig 中配置过的
     */
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private SecurityUserServiceImpl 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);
    }
}

简单测试

启动auth服务,调用接口 http://localhost:9000/oauth/token

www.cnblogs.com/yanch01/tag…

www.cnblogs.com/haoxianrui/…


优化认证服务

使用JWT加强token

在AuthorizationServerConfig添加相关配置

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService)
                // 设置token转换器
                .accessTokenConverter(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

    @Bean
    public KeyPair keyPair() {
        // 这里的password需要与之前创建时输入的一致,否则会无法读取导致服务启动失败
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt");
    }

简单测试

报错:class path resource [jwt.jks] cannot be opened because it does not exist

原因:编译不成功,文件没有在target下

暂时手动加文件复制到target/classes/young/ 目录下

调用接口

JWT中添加自定义字段

AuthorizationServerConfig中设置实现TokenEnhancer接口进行内容增强,

在其中添加一些自定义字段,如在Token中添加userId字段

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService)
                // 设置token转换器
                .accessTokenConverter(accessTokenConverter());
        // 将两个增强器连起来
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer(), accessTokenConverter()));
        endpoints.tokenEnhancer(tokenEnhancerChain);
    }

    /** JWT内容增强,在其中添加一些自定义字段 */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return (accessToken, authentication) -> {
            Map<String, Object> additionalInfo = new HashMap<>();
            SecurityUser user = (SecurityUser) authentication.getPrincipal();
            additionalInfo.put("userId", user.getId());
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
            return accessToken;
        };
    }

调用接口测试成功

自定义oauth/token 返回数据结构

SpringSecurity提供的auth接口如下

故只需要自定义认证接口,在接口内调用该函数,

将返回的OAuth2AccessToken包装为自定义的数据结构,即可具体地定义返回数据结构

@RestController
@RequestMapping("/oauth")
public class AuthController {
 
    @Autowired
    private TokenEndpoint tokenEndpoint;
 
    @PostMapping(value = "/token")
    public CommonResult<TokenDTO> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
        OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
 
        TokenDTO tokenDTO = TokenDTO.builder().token(oAuth2AccessToken.getValue())
                .refreshToken(oAuth2AccessToken.getRefreshToken().getValue())
                .tokenHead("Bearer ").build();
        return CommonResult.success(tokenDTO);
    }
}
  • 调用gateway路由auth:/auth/oauth/token 获取token失败

    • 没有放行这个接口,配置WhiteListUrlsConfig
  • Caused by: java.text.ParseException: Missing required "keys" member 解析失败

    • 经测试,直接调用认证模块可以返回,但是从网关调用就失败。因为所有返回参数被包装了,所以无法解析到公钥的keys