spring-cloud-2020.0.3-oauth2 + jwt 实现微服务安全认证
1、创建maven基本parent项目
创建一个passport-parent父项目,其中包含了两个模块(module)为passport和account,passport即认证授权的服务,account用来模拟资源服务,将在account服务内部获取到用户的认证授权信息。
- passport-parent对应的pom.xml文件如下(去除非关键部分)
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.5.2
com.minxyz.passport
passport-parent
0.0.1
passport-parent
passport-parent project for Spring Boot
pom
1.8
2020.0.3
passport
account
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-oauth2
2.2.5.RELEASE
org.springframework.security
spring-security-jwt
1.1.1.RELEASE
com.auth0
java-jwt
3.17.0
org.springframework.boot
spring-boot-starter-security
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.cloud
spring-cloud-dependencies
${spring-cloud.version}
pom
import
org.springframework.boot
spring-boot-maven-plugin
org.projectlombok
lombok
spring-cloud版本使用2020.0.3,jdk版本使用1.8
2、创建module授权服务器(passport)
即用户登录服务器,类似常见的如passport.jd.com\sso.qq.com
- 2.1、创建passport项目,其依赖passport-parent父项目(删除非关键信息)
pom.xml配置文件
4.0.0
com.minxyz.passport
passport-parent
0.0.1
com.minxyz.passport
passport
0.0.1
passport
passport project for Spring Boot
AuthorizationServerConfig.java 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore()).accessTokenConverter(accessTokenConverter())
.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("account").secret("account123").scopes("read", "write")
.authorizedGrantTypes("password", "authorization_code", "refresh_token");
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("1234567890");
return converter;
}
@Bean
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
}
WebSecurityConfig.java配置文件配置SpringSecurity相关配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/api/**").permitAll()
.anyRequest().authenticated();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("admin").password("123456")
.roles("admin", "user");
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return Objects.equals(charSequence.toString(), s);
}
};
}
}
PassportApplication.java 主应用程序入口
@SpringBootApplication
public class PassportApplication {
public static void main(String[] args) {
SpringApplication.run(PassportApplication.class, args);
}
}
- 2.2、启动应用,获取用户授权token
通过
curl -X POST -u account:account123 "http://localhost:8080/oauth/token?grant_type=password&username=admin&password=123456"
请求授权服务器,即可获取到用户的token,内容如下
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx",
"expires_in": 43199,
"scope": "read write",
"jti": "19516ea7-3ac4-4d5a-8bf8-bfb02a027701"
}
字段 | 说明 |
---|---|
access_token | 授权token |
token_type | token类型 |
refresh_token | 刷新token(access_token过期时可使用此token更换) |
expires_in | token过期时间 |
scope | 授权范围 |
jti | jwt的唯一标识(jwt id) |
当access_token快过期时,可使用refresh_token再次更换access_token,注意refresh_token若过期,则需要重新登录验证了
刷新token请求:curl -X POST -u account:account123 -d "grant_type=refresh_token&refresh_token=xxx" localhost:8080/oauth/token
接下来就可以使用这个access_token去访问其他微服务应用(资源服务器)了,在应用中即可获取用户的认证授权信息
3、创建module资源服务器(account)(即对应需要使用到用户认证授权的微服务)
- 3.1、创建account项目用来模拟微服务获取用户的认证授权信息,其依赖passport-parent父项目(删除非关键信息)
pom.xml配置文件
4.0.0
com.minxyz.passport
passport-parent
0.0.1
com.minxyz.passport
account
0.0.1
account
account project for Spring Boot
ResourceServerConfig.java
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer config) {
config.tokenServices(tokenServices());
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("1234567890"); //需与授权服务器配置的保持一致
return converter;
}
@Bean
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/**").hasAnyRole("admin")
.anyRequest().authenticated();
}
编写一个AccountController.java用来测试
@Slf4j
@RestController
@RequestMapping("/api/account")
public class AccountController {
@GetMapping("/{id}")
public String find(@PathVariable("id") String id) {
log.info(id);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.info("{}", authentication);
return "success";
}
}
编写主应用程序
@SpringBootApplication
public class AccountApplication {
public static void main(String[] args) {
SpringApplication.run(AccountApplication.class, args);
}
}
- 3.2、启动应用,拿到从授权服务器中获取的token请求
localhost:8081/api/account/123
即可通过
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release-1分支代码
4、jwt的token增强功能
- 4.1、jwt格式
jwt由三部分组成,第一部分为头部(header基本都是一样),第二部分为载荷(payload),第三部分是签名(signature),其中第一、二部分使用Base64编码输出,可以通过Base64完成解码,注意两部分要分开解码获取原始信息。
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" |base64 --decode
echo "eyJleHAiOjE2MjU2OTY1MzEsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hZG1pbiIsIlJPTEVfdXNlciJdLCJqdGkiOiIzZmQ2OGQ2NS1jMTNiLTQ2YjYtOGQ3Mi04YTFlYWQ5M2QxZjgiLCJjbGllbnRfaWQiOiJhY2NvdW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19" |base64 --decode
源码解析,查看
JwtAccessTokenConverter.java
的encode
方法
protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
String content;
try {
content = objectMapper.formatMap(tokenConverter.convertAccessToken(accessToken, authentication));
}
catch (Exception e) {
throw new IllegalStateException("Cannot convert access token to JSON", e);
}
String token = JwtHelper.encode(content, signer).getEncoded();
return token;
}
public static Jwt encode(CharSequence content, Signer signer, Map headers) {
JwtHeader header = JwtHeaderHelper.create(signer, headers);
byte[] claims = Codecs.utf8Encode(content);
byte[] crypto = signer.sign(Codecs.concat(new byte[][]{Codecs.b64UrlEncode(header.bytes()), PERIOD, Codecs.b64UrlEncode(claims)}));
return new JwtImpl(header, claims, crypto);
}
其中的
JwtHelper.encode(content, signer).getEncoded();
即为将原始content编码后输出,接着拿一个已生成好的access_token分析一下"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjU2OTY1MzEsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hZG1pbiIsIlJPTEVfdXNlciJdLCJqdGkiOiIzZmQ2OGQ2NS1jMTNiLTQ2YjYtOGQ3Mi04YTFlYWQ5M2QxZjgiLCJjbGllbnRfaWQiOiJhY2NvdW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19.TqLBchsqUi_KE2vhadniPz3aAfguaLB7q1PPfkeORrI"
,access_token按句号分隔成3部分,第一部分解码后为{"alg":"HS256","typ":"JWT"}
,默认签名算法为HS256,此部分为固定值,第二部分解码后为{"exp":1625696531,"user_name":"admin","authorities":["ROLE_admin","ROLE_user"],"jti":"3fd68d65-c13b-46b6-8d72-8a1ead93d1f8","client_id":"account","scope":["read","write"]}
包含有用户名、过期时间及授权信息,第三部分签名根据第一、二部分通过signer.sign生成。
4.2、增强jwt(在jwt中增加自定义信息)
- passport应用
通常我们需要在jwt中增加一些额外的信息,以便在token中直接获取,从而避免一些接口请求,如用户登录ip,可以通过TokenEnhancer实现。
编写UserTokenEnhancer.java,其主要方法是读取现有的token,再根据方法setAdditionalInformation增加一些额外信息,实际应用中应该获取到用户的一些全局信息放入此字段中,实现如下,
@Component
public class UserTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
if (accessToken instanceof DefaultOAuth2AccessToken) {
Map additional = new HashMap<>();
Map param = new HashMap<>();
param.put("aa","bb");
param.put("cc","dd");
additional.put("additional", param);
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additional);
}
return accessToken;
}
}
修改AuthorizationServerConfig配置,将tokenEnhancer加入到链中
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain chain = new TokenEnhancerChain();
chain.setTokenEnhancers(Arrays.asList(userTokenEnhancer,accessTokenConverter()));
endpoints.tokenStore(tokenStore()).tokenEnhancer(chain)
.authenticationManager(authenticationManager);
}
- account应用
在account应用中可通过以下的方法获取到用户jwt中的额外信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof OAuth2Authentication) {
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
OAuth2AccessToken accessToken = new JwtTokenStore(accessTokenConverter).readAccessToken(details.getTokenValue());
log.info("{}", accessToken.getAdditionalInformation());
}
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/token_enhancer分支代码
5、密码加密功能
前面的passport项目中为了简单起见,使用了内存用户
inMemoryAuthentication
,并且为明文密码123456,为了更安全我们使用BCryptPasswordEncoder进行加密存储,准确地说是hash算法(不可逆),将计算出的的结果保存。在密码匹配的阶段并不是对密码进行解密,而是使用相同的算法将用户输入的密码进行hash处理,得到新的hash值,再使用matches方法比较两个hash值是否为相同密码。可以执行以下测试代码,结果均为true,s1、s2、s3为不相同密文。
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String s1 = encoder.encode("123456");
String s2 = encoder.encode("123456");
String s3= encoder.encode("123456");
System.out.println(s1);
System.out.println(s2);
System.out.println(s3);
System.out.println(encoder.matches("123456",s1));
System.out.println(encoder.matches("123456",s2));
System.out.println(encoder.matches("123456",s3));
- 应用
BCryptPasswordEncoder
依然使用内存用户并将密码encoder改为此类型,代码如下,其中password使用encode方法生成
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder()).withUser("admin").password("$2a$10$y2a1hrhZXSDj06tbwaA0bOQCpFAdgOXtt3/T2ml8GUacsPMB.338W")
.roles("admin", "user");
}
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/bcrypt_password_encoder分支代码
需要注意与PasswordEncoder默认bean不能混淆,因为oauth2对应的client端用户名、密码认证同样使用到此类,所以需保留简单用户名密码的校验方式。
6、修改jwt的access_token及refresh_token有效期并使用数据库存储(jdbc)
默认情况下生成的access_token有效期为24h,refresh_token有效期为30d,可通过以下方式简单设置,将access_token和refresh_token分别设置为1h和12h。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("account").secret("account123").scopes("read", "write")
.authorizedGrantTypes("password", "authorization_code", "refresh_token")
.additionalInformation().accessTokenValiditySeconds(3600).refreshTokenValiditySeconds(3600*12);
}
- 配置使用jdbc的方式存储oauth2的相关配置信息(可以观察到查询语句,此内容为低频修改可使用缓存)
演示使用mysql,建一张表(若只使用到password模式,oauth_client_details表即可),并插入一条测试数据,如下
CREATE TABLE `oauth_client_details` (
`client_id` varchar(256) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `passport`.`oauth_client_details` (`client_id`, `resource_ids`, `client_secret`, `scope`, `authorized_grant_types`, `web_server_redirect_uri`, `authorities`, `access_token_validity`, `refresh_token_validity`, `additional_information`, `autoapprove`) VALUES ('account', '', 'account123', 'read,write', 'password,authorization_code,refresh_token', 'http://www.mixfate.com/user', '', '3600', '7200', '{\"a\":\"c\"}', 'true');
autoapprove为true表示授权码模式时,用户输入用户名及密码后直接跳转到指定页,不需要经过授权确认页完成确认;resource_ids表示允许访问的资源,若为空表示不限制,需与资源服务器的id对应。
将ClientDetailsServiceConfigurer配置修改如下,然后配置好mysql对应的datasource,此时password模式即可正常访问。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
- 使用授权码模式完成认证授权
授权码模式需要增加
httpBasic()
授权认证,调整如下
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/api/**").permitAll()
.anyRequest().authenticated().and().httpBasic();
}
使用浏览器打开
http://127.0.0.1:8080/oauth/authorize?client_id=account&response_type=code&redirect_uri=http://www.mixfate.com/user&state=123456
,通过basic验证后(用户名admin,密码123456)即会跳转到redirect_uri页面,并在url后带上code参数,此code即用于换access_token,为了演示方便拿到此code继续请求授权地址localhost:8080/oauth/token?grant_type=authorization_code&code=Btxjsx&redirect_uri=http://www.mixfate.com/user
即可获取token,可使用curl测试如下
D:\>curl -i -u admin:123456 "http://127.0.0.1:8080/oauth/authorize?client_id=account&response_type=code&redirect_uri=http://www.mixfate.com/user&state=123456"
HTTP/1.1 302
...
Location: http://www.mixfate.com/user?code=7MAv5W&state=123456
...
D:\>curl -u account:account123 -X POST "http://localhost:8080/oauth/token?grant_type=authorization_code&code=7MAv5W&redirect_uri=http://www.mixfate.com/user"
...
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/auth_database分支代码
7、采用非对称加密算法生成JWT令牌
前面jwt签名算法key使用了固定的1234567890,安全性不高,更好的方式是使用rsa非对称加密算法签名。
- 如需要获取token对应的key,或校验token key是否正常,增加以下配置允许访问即可,表示允许获取token key和校验token的有效性
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients().tokenKeyAccess("permitAll()").checkTokenAccess("permitAll()");
}
获取token key如:
curl -u account:account123 http://localhost:8080/oauth/token_key
校验token 如:curl -X POST -u account:account123 "localhost:8080/oauth/check_token?token=xxx"
- 使用keytool生成公钥和私钥,并配置到授权服务
生成passport.keystore密钥库
keytool -genkeypair -alias passport -keyalg RSA -dname "CN=Passport,OU=China,O=www.mixfate.com,L=Beijing,S=Beijing,C=China" -keypass s123456 -keystore passport.keystore -storepass s123456
查看密钥库证书详情,输入证书库密码s123456
keytool -list -keystore passport.keystore
从passport.keystore密钥库中导出公钥(注意需安装openssl),还可直接使用接口查看公钥
curl -u account:account123 http://localhost:8080/oauth/token_key
,但需配置好passport并启动应用
keytool -list -rfc --keystore passport.keystore | openssl x509 -inform pem -pubkey
将JwtAccessTokenConverter配置修改如下,启动后可用接口获取公钥
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("passport.keystore"), "s123456".toCharArray());
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("passport"));
return converter;
}
如获取的公钥如下
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlt4nuFdUTYl35h+9dU5t
Oxajub12EGyhmiOavCnHrADTUX2YEjbKqi1yLaXu70wmqfvLjR2tzHcV6rGWSGUS
ThXaPMdlBk6hS8vNOQoUFwVDHdsYoMkyeo6g9B4lmzUBd8y3q1SJDCitJrAf2Nh3
HxQxgCXllJ+YUwrIIx0xCCs4cyAtugxz9p8byPBegcDYhN3+9530O8u3o+7zrYa+
KFiBzLfBORy5KUHxqzy/x//DtWuLf4SLhdZdgfGQj6xI0BE+0vdiyHHPq2inLxhB
V8AMSKkn/CJhxAGg2IDAkfJ/nf24t8vDxzfKXXz0/yXArK6gUAYXNPMzTOZaGgQd
VwIDAQAB
-----END PUBLIC KEY-----
- 将公钥拷贝到资源服务并完成验证
资源服务account配置文件application.yaml增加配置如下
security:
oauth2:
resource:
jwt:
key-value: |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlt4nuFdUTYl35h+9dU5t
Oxajub12EGyhmiOavCnHrADTUX2YEjbKqi1yLaXu70wmqfvLjR2tzHcV6rGWSGUS
ThXaPMdlBk6hS8vNOQoUFwVDHdsYoMkyeo6g9B4lmzUBd8y3q1SJDCitJrAf2Nh3
HxQxgCXllJ+YUwrIIx0xCCs4cyAtugxz9p8byPBegcDYhN3+9530O8u3o+7zrYa+
KFiBzLfBORy5KUHxqzy/x//DtWuLf4SLhdZdgfGQj6xI0BE+0vdiyHHPq2inLxhB
V8AMSKkn/CJhxAGg2IDAkfJ/nf24t8vDxzfKXXz0/yXArK6gUAYXNPMzTOZaGgQd
VwIDAQAB
-----END PUBLIC KEY-----
同时注释掉原使用固定signKey的convert注册,改为自动注入即可
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter);
}
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/rsa 分支代码
8、授权服务tokenstore改为使用redis存储
- 授权服务passport增加redis配置
org.springframework.boot
spring-boot-starter-data-redis
- AuthorizationServerConfig.java配置文件修改如下
@Autowired
private RedisConnectionFactory connectionFactory;
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(connectionFactory);
}
- application.yaml配置文件增加redis配置
spring:
redis:
password: mix2021
host: 127.0.0.1
port: 6379
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/redis_token_store分支代码
9、资源服务account配置EnableGlobalMethodSecurity,限制授权访问
- 增加WebSecurityConfig.java配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
}
- AccountController的find方法增加一行配置,同时passport的类WebSecurityConfig配置中roles增加test
@PreAuthorize("hasRole('ROLE_TEST')")
可从gitee中获取 https://gitee.com/viturefree/passport 对应其中的release/enable_global_method_security 分支代码