Spring Security OAuth2目录
前言
目前来说,微服务下的Spring Security Oauth2在最新版已经停止维护,重要的是思想和设计,所以了解还是存在必要性的。
本文中使用的 spring-cloud-starter-oauth2 对应的 Spring Boot 版本为 2.3.9.RELEASE。(新版停止维护)
本文案例代码:代码仓库:https://github.com/Mrsulin/spring-security-oauth2
OAuth2相关名词
- Resource Owner:资源所有者,一般就是用户的账号和密码。
- Resource Server:资源服务器,用户需要访问的资源(需要携带令牌进行访问)
- Client:客户端,通常是指第三方应用程序。
- Authorization Server:授权服务器,用来认证的服务器。
- User Agent:用户代理,常常指浏览器。
OAuth2与SSO单点登录
OAuth2主要作用:
允许第三方应用访问其他服务提供者上的信息,且不需要将其他服务提供者的用户和密码提供给第三方应用。
以携带服务提供者颁发的令牌的方式来访问服务提供者上的数据。
举个例子来说:
- 初次登录某网站时,选择QQ登录;
- 登录QQ成功,界面提示是否给该网站授权(一般是能够获取用户名、年龄、性别、头像的基本权限);
- 确认授权,网站获取QQ基本信息,并帮你自动注册账号到网站;
OAuth2并不是为了单点登录而生,只是说通过OAuth2可以实现SSO单点登录。这套方案的落地产品之一就是:Spring Security Oauth2。另外实现单点登录还可以考虑这些框架组合:Shiro、CAS、KeyCloak、SAML2等等。
4种认证模式
- 授权码模式 —authorization_code
- 静默模式 —implicit
- 密码模式 —password
- 客户端模式 —client_credentials
授权码模式
静默模式
静默模式其实没什么好说的,简化了授权码这一步骤,直接就可以获取token令牌
密码模式
密码模式实际上是微服务中实现最多的,密码会交由客户端,然后客户端去访问服务提供商。由于密码完全交由了客户端,一般只在客户端和提供商高度信任的情况下使用。甚至通常只是公司内部的服务之间的调用,例如微服务间的调用。
客户端模式
客户端模式就是不需要登录,但是获取的权限很低,仅仅是资源服务器提供的一些登录即可用的接口,表现在Spring Cloud中就是只能访问一些没有被权限注解修饰的接口。
代码实现
代码仓库:https://github.com/Mrsulin/spring-security-oauth2
实现时主要关注两个类:AuthorizationServerConfigurerAdapter 和WebSecurityConfigurerAdapter 。
前者是Oauth2相关的类,后者是和SpringSecurity集成需要的一些配置。
方法功能在代码中有注释。这里提一下几个重点。
- 注入TokenStore时,可以选择JWT,也可以用Redis存储 PasswordEncoder 加密后的数据;
- 密码模式不需要登录,所以提前注入AuthenticationManager 并暴露给token存储;
- 使用refresh_token时注意要 在token断点的暴露代码中指定userDetailService并且需要手动开启该功能:reuseRefreshTokens(true);
- 其他资源服务器(其他服务)需要在启动类中添加@EnableResourceServer注解;
- 授权服务的启动类注意添加@EnableAuthorizationServer注解
- 如果需要用权限注解记得添加@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;
}
/**
* 配置第三方应用
* <p>
* 四种授权类型:
* "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
- 授权服务和其他子服务持有相同盐;
- 子服务需要指定一个授权服务提供的Http接口
采用这种方式需要在授权服务和其他服务持有相同的盐,并且使用Token访问其他的服务时,其他服务需要再到授权服务进行一次验证。属于额外的网络开销,并且不安全,因为攻破任意一个子服务都会泄露盐。
非对称加密
- 非对称加密时,采用秘钥对的方式。授权服务器持有私钥,其他子服务持有公钥。
- 单独攻破子服务,只能拿到公钥。没有用。
- 不需要去访问授权服务器提供的验证接口验证,避免额外开销。
实现时需要生产公钥和私钥。可以使用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.如图
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());
}
秘钥对
- 公钥负责加密与验章
- 私钥负责解密与签章
其实此处的使用应该算是私钥签章,公钥验章。
本例使用时是为JWT生成的token的第三段(头部、载荷、第三段则是签证)添加秘钥。而token携带的内容实际上是可解析的。但是确定它是否真的是私钥发布的,则是由子服务的公钥进行验章。
结合网关使用
结合网关时,由于所有的前端请求都要先走网关。所以一些如登录的接口需要放行。
关于登出
采用Jwt做为token时,还是会遇到问题。比如无法过期,因为token一旦颁发,除非过期,否则无法作废。
但是也有解决思路:添加redis记录当前颁发的token状态,并且在网关统一验证,避免使用过期token或者伪造token。
- 登录时redis记录;
- 走网关时校验token是否在redis中存在;
虽然能解决登出问题,不过会把JWT无状态这一特性变为有状态。所以该怎么选择还是需要看场景。
也有分布式事务这样的选择。比如Spring下的分布式事务Spring Session。