单点登录实现——Spring Security OAuth2

Spring Security OAuth2目录

    • 前言
    • OAuth2相关名词
    • OAuth2与SSO单点登录
    • 4种认证模式
          • 授权码模式
          • 静默模式
          • 密码模式
          • 客户端模式
    • 代码实现
    • OAuth2与JWT
        • 对称加密
        • 非对称加密
        • 秘钥对
    • 结合网关使用
        • 关于登出

前言

目前来说,微服务下的Spring Security Oauth2在最新版已经停止维护,重要的是思想和设计,所以了解还是存在必要性的。
本文中使用的 spring-cloud-starter-oauth2 对应的 Spring Boot 版本为 2.3.9.RELEASE。(新版停止维护)

本文案例代码:代码仓库:https://github.com/Mrsulin/spring-security-oauth2

OAuth2相关名词

  1. Resource Owner:资源所有者,一般就是用户的账号和密码。
  2. Resource Server:资源服务器,用户需要访问的资源(需要携带令牌进行访问)
  3. Client:客户端,通常是指第三方应用程序。
  4. Authorization Server:授权服务器,用来认证的服务器。
  5. User Agent:用户代理,常常指浏览器。

OAuth2与SSO单点登录

OAuth2主要作用:

允许第三方应用访问其他服务提供者上的信息,且不需要将其他服务提供者的用户和密码提供给第三方应用。

以携带服务提供者颁发的令牌的方式来访问服务提供者上的数据。

举个例子来说:

  1. 初次登录某网站时,选择QQ登录;
  2. 登录QQ成功,界面提示是否给该网站授权(一般是能够获取用户名、年龄、性别、头像的基本权限);
  3. 确认授权,网站获取QQ基本信息,并帮你自动注册账号到网站;

OAuth2并不是为了单点登录而生,只是说通过OAuth2可以实现SSO单点登录。这套方案的落地产品之一就是:Spring Security Oauth2。另外实现单点登录还可以考虑这些框架组合:Shiro、CAS、KeyCloak、SAML2等等。

4种认证模式

  • 授权码模式 —authorization_code
  • 静默模式 —implicit
  • 密码模式 —password
  • 客户端模式 —client_credentials
授权码模式

单点登录实现——Spring Security OAuth2_第1张图片

静默模式

静默模式其实没什么好说的,简化了授权码这一步骤,直接就可以获取token令牌

密码模式

密码模式实际上是微服务中实现最多的,密码会交由客户端,然后客户端去访问服务提供商。由于密码完全交由了客户端,一般只在客户端和提供商高度信任的情况下使用。甚至通常只是公司内部的服务之间的调用,例如微服务间的调用
单点登录实现——Spring Security OAuth2_第2张图片

客户端模式

客户端模式就是不需要登录,但是获取的权限很低,仅仅是资源服务器提供的一些登录即可用的接口,表现在Spring Cloud中就是只能访问一些没有被权限注解修饰的接口。

代码实现

代码仓库:https://github.com/Mrsulin/spring-security-oauth2

实现时主要关注两个类:AuthorizationServerConfigurerAdapterWebSecurityConfigurerAdapter
前者是Oauth2相关的类,后者是和SpringSecurity集成需要的一些配置。
方法功能在代码中有注释。这里提一下几个重点。

  1. 注入TokenStore时,可以选择JWT,也可以用Redis存储 PasswordEncoder 加密后的数据;
  2. 密码模式不需要登录,所以提前注入AuthenticationManager 并暴露给token存储;
  3. 使用refresh_token时注意要 在token断点的暴露代码中指定userDetailService并且需要手动开启该功能:reuseRefreshTokens(true);
  4. 其他资源服务器(其他服务)需要在启动类中添加@EnableResourceServer注解;
  5. 授权服务的启动类注意添加@EnableAuthorizationServer注解
  6. 如果需要用权限注解记得添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解

参考代码(此处主要是密码模式的授权服务器端代码)

@Configuration
public class AuthorizationJwtConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 让oauth的token与jwt做关联
     *
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
    /**
     * jwtToken转换器
     *
     * @return jwt rsa加密
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        //为转换器提供一个签名----------对称加密
        //jwtAccessTokenConverter.setSigningKey(CommonConstant.SIGN_KEY);
        //rsa加密
        ClassPathResource resource = new ClassPathResource("jwt.jks");
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource,"ku_9527".toCharArray());
        //如果keyPass和storePass密码不一致,则需要添加第二个参数 keyPass
        KeyPair keyPair = keyStoreKeyFactory.getKeyPair("rsa_for_jwt","key_9527".toCharArray());
        PrivateKey aPrivate = keyPair.getPrivate();
        PublicKey aPublic = keyPair.getPublic();

        jwtAccessTokenConverter.setKeyPair(keyPair);
        return jwtAccessTokenConverter;
    }

    /**
     * 配置第三方应用
     * 

* 四种授权类型: * "authorization_code", "implicit", "password", "client_credentials", "refresh_token" * -1.Code码授权 authorization_code * -2.静默授权 implicit * -3.密码授权 (特别信任第三方应用) password * -4.客户端授权(直接通过浏览器获取token) client_credentials * * @param clients client * @throws Exception e */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory().withClient("code-client") .secret(passwordEncoder.encode("code-client-123")) .scopes("read", "write")//客户端的业务作用域,自己定义的 .authorizedGrantTypes("authorization_code")//四种授权类型 .accessTokenValiditySeconds(7200)//token的有效时间 .redirectUris("https://www.baidu.com")//授权后跳转的URI地址 .and() .withClient("direct-client") .secret(passwordEncoder.encode("direct-client-123")) .scopes("read", "write") .authorizedGrantTypes("implicit") //静默模式 .accessTokenValiditySeconds(3600) .redirectUris("https://www.baidu.com") .and() .withClient("password-client") .secret(passwordEncoder.encode("password-client-123")) .scopes("read", "write") .authorizedGrantTypes("password","refresh_token") //密码模式 .accessTokenValiditySeconds(3600) .redirectUris("https://www.baidu.com") .and() .withClient("client") .secret(passwordEncoder.encode("client-123")) .authorizedGrantTypes("client_credentials") //客户端模式 不需要登录,也不需要授权,一般token时间比较短 访问最基本的接口(例如没有被权限注解管理的普通接口) .scopes("r--", "w--") .accessTokenValiditySeconds(3600) .redirectUris("https://www.baidu.com")//授权后跳转的URI地址 ; } /** * 注入authenticationManager ,(在securityConfig中将该配置类注入) * 来支持 password grant type */ @Autowired private AuthenticationManager authenticationManager; /** * 暴露token授权服务给token的存储 * @param endpoints 断点 * @throws Exception e */ @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore()) .authenticationManager(authenticationManager) //还需要将转换器放到授权暴露端点中 .accessTokenConverter(jwtAccessTokenConverter()) //refresh_Token时需要userDetailService .userDetailsService(userDetailService) .reuseRefreshTokens(true); } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.allowFormAuthenticationForClients().checkTokenAccess("permitAll()"); } }

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private UserDetailServiceImpl userDetailService;

    /**
     * 配置登录信息
     *
     * @param auth builder
     * @throws Exception e
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailService);
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().disable();
        http.formLogin();
        http.authorizeRequests().anyRequest().authenticated();
    }
    /**
     * 密码授权不需要登录,内部仅需要一个认证管理器,所以,此时将登录认证器注入到Spring,在后面使用
     *
     * @return e
     * @throws Exception e
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/swagger-ui.html")
                .antMatchers("/webjars/**")
                .antMatchers("/v2/**")
                .antMatchers("/swagger-resources/**");
    }
}

OAuth2与JWT

使用Spring Security Oauth2时,常常使用JWT作为Token实现方式。
而使用JWT时也可以考虑使用对称加密和非对称加密这两种方式。

对称加密

使用对称加密时涉及的代码如下:
授权服务端:

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        //为转换器提供一个签名----------对称加密
        jwtAccessTokenConverter.setSigningKey(CommonConstant.SIGN_KEY);
        return jwtAccessTokenConverter;
    }
	//提供给其他服务的验证接口
    @GetMapping("getUserInfo")
    public Principal getUserInfo(Principal principal){
        return principal;
    }

其他服务

security:
  oauth2:
    resource:
      user-info-uri:  http://localhost:9999/oauth/getUserInfo
  1. 授权服务和其他子服务持有相同盐;
  2. 子服务需要指定一个授权服务提供的Http接口

采用这种方式需要在授权服务和其他服务持有相同的盐,并且使用Token访问其他的服务时,其他服务需要再到授权服务进行一次验证。属于额外的网络开销,并且不安全,因为攻破任意一个子服务都会泄露盐。

非对称加密
  1. 非对称加密时,采用秘钥对的方式。授权服务器持有私钥,其他子服务持有公钥
  2. 单独攻破子服务,只能拿到公钥。没有用。
  3. 不需要去访问授权服务器提供的验证接口验证,避免额外开销。

实现时需要生产公钥和私钥。可以使用Jdk自带的Keytool组合OpenSSL。

生成秘钥
1、下载OpenSSL并且输入以下命令,生成.jks文件(存储秘钥对的容器)
注意以下几个参数keypass、storepass

keytool -genkeypair -alias rsa_for_jwt -keypass key_9527 -keystore jwt.jks -storepass ku_9527 -validity 36500 -keyalg RSA

2获取公私钥,此时会提示输入上面的密码,然后获取公私钥。

keytool -genkeypair -alias rsa_for_jwt -keypass key_9527 -keystore jwt.jks -storepass ku_9527 -validity 36500 -keyalg RSA

3.如图
单点登录实现——Spring Security OAuth2_第3张图片
4.生成的jwt.jks放置在授权服务器Resource文件下,并且修改授权服务器配置

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        //rsa加密
        ClassPathResource resource = new ClassPathResource("jwt.jks");
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource,"ku_9527".toCharArray());
        //如果keyPass和storePass密码不一致,则需要添加第二个参数 keyPass
        KeyPair keyPair = keyStoreKeyFactory.getKeyPair("rsa_for_jwt","key_9527".toCharArray());
        jwtAccessTokenConverter.setKeyPair(keyPair);
        return jwtAccessTokenConverter;
    }

5.公钥放在子服务Resource文件下。修改配置信息

  /**
     * 配置解析,也就是反转换
     *
     * @return JwtAccessTokenConverter
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        //非对称解密
        ClassPathResource classPathResource = new ClassPathResource("public.txt");
        String publicKey = null;
        try {
            publicKey = FileUtil.readString(classPathResource.getFile(), Charset.defaultCharset());
        } catch (IOException e) {
            e.printStackTrace();
        }
        jwtAccessTokenConverter.setVerifierKey(publicKey);
        return jwtAccessTokenConverter;
    }
    /**
     * 告诉资源服务器从哪里获取token,让资源服务器从tokenStore中获取token
     *
     * @param resources 资源
     * @throws Exception e
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenStore(tokenStore());
    }
秘钥对
  1. 公钥负责加密与验章
  2. 私钥负责解密与签章

其实此处的使用应该算是私钥签章,公钥验章
本例使用时是为JWT生成的token的第三段(头部、载荷、第三段则是签证)添加秘钥。而token携带的内容实际上是可解析的。但是确定它是否真的是私钥发布的,则是由子服务的公钥进行验章。

结合网关使用

结合网关时,由于所有的前端请求都要先走网关。所以一些如登录的接口需要放行。

关于登出

采用Jwt做为token时,还是会遇到问题。比如无法过期,因为token一旦颁发,除非过期,否则无法作废。
但是也有解决思路:添加redis记录当前颁发的token状态,并且在网关统一验证,避免使用过期token或者伪造token。

  1. 登录时redis记录;
  2. 走网关时校验token是否在redis中存在;

虽然能解决登出问题,不过会把JWT无状态这一特性变为有状态。所以该怎么选择还是需要看场景。
也有分布式事务这样的选择。比如Spring下的分布式事务Spring Session

你可能感兴趣的:(单身终结者:Java,Ta在说什么:Java框架,java,spring,oauth2,微服务架构)