一、前记
记得很久之前面试美的,当时吹水微服务,被人问到一个问题:微服务鉴权如何处理的?嗯,很简单一个问题,但是懵逼了(水平不行啊),自然就挂了,后面就想以后吹水微服务一定要做好鉴权这一块的内容。
二、准备知识
Spring Security、OAuth2.0、Jwt;网上有很多内容,这里不赘述。
OAuth2.0主要学几种模式,如密码模式、客户端模式等等,这里我们使用密码模式。
三、参考文章
本文参考了这几个大神的文章,1)https://www.jianshu.com/p/24764aba1012?utm_source=oschina-app;
2)https://www.jianshu.com/p/59dea41787c9
第一个文章是Spring Cloud相关内容,不是特别适合入门,所以我去除了SC的内容;第一篇改为内置InMemory数据库;第一篇文章请求Jwt token使用了SC的FeignClient,返回的Jwt对象还自己写pojo定义了,实际上Spring Security OAuth已经有请求相关对象(ResourceOwnerPasswordAccessTokenProvider,谢谢第二篇),所以我进行了替换;第一篇文章认证服务器、资源服务器分开部署,有两个bean同名容易困扰,本篇认证服务器、资源服务器是统一部署的,没有混杂的东西,新手容易入手;第一篇还有一个Jwt公钥Public.cert直接保存在服务器读取的,不是很安全啊,我没有用这种方式。
可以说两篇融(chao)合(xi)了这两篇以后,加上自己的思考,就有了我这第三篇。
四、微服务请求流程
盗第一篇的图:
我是这样设计的,开放三个rest api对外提供访问:login、user、admin;其中user需要user权限的用户可以访问、admin需要admin用户权限、login无限制访问,用户名密码认证成功后返回access token,后续访问user、admin携带该token,权限无误后可以访问。
五、程序设计
1)WebSecurityConfig
功能:提供内置用户名密码数据库、提供AuthenticationManager给OAuth使用、配置需要认证的路径
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter
a.建立test、admin、dba账户,对应不同权限;passwordEncoder是BCryptPasswordEncoder
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("test").password(passwordEncoder.encode("test")).roles("USER").build());
manager.createUser(User.withUsername("admin").password(passwordEncoder.encode("admin")).roles("USER","ADMIN").build());
manager.createUser(User.withUsername("dba").password(passwordEncoder.encode("dba")).roles("USER","DBA").build());
return manager;
}
b.配置认证路径(Spring Security)
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // 使用jwt,可以允许跨域
.exceptionHandling() // 错误处理
.authenticationEntryPoint(new UnauthorizedEntryPoint()) // 内部类
.and()
.authorizeRequests()
//.antMatchers("/oauth/token").permitAll()
.antMatchers("/**").authenticated() // 所有请求都要认证
.and().httpBasic(); // http_basic方式进行认证
}
自己定义了一个内部类用于错误处理,假如是ajax请求,错误返回未认证错误信息,假如非ajax,则跳转到特定页面(当然我没有这个页面,这里懒得改了)
class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
if (isAjaxRequest(request)) {
// ajax请求,返回错误代码
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
} else {
// 非ajax请求,跳转到login界面
response.sendRedirect("/login");
}
}
public boolean isAjaxRequest(HttpServletRequest request) {
String ajaxFlag = request.getHeader("X-Requested-With");
return "XMLHttpRequest".equals(ajaxFlag);
}
}
c.定义一个bean名称为MyAuthManager,使得后面OAuth服务可以使用我们这里定义的认证管理器AuthenticationManager
@Bean(name = "MyAuthManager")
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
2)Jwt Token配置
功能:主要是提供token转换器、token存储(本文为内存存储,真正一般使用redis)
@Configuration
public class JwtConfig {
@Bean
public JwtAccessTokenConverter jwtTokenEnhancer() throws IOException{
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("cnsesan-jwt.jks"),
"cnsesan123".toCharArray());
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("cnsesan-jwt"));
return converter;
}
@Bean
@Autowired
public TokenStore tokenStore(JwtAccessTokenConverter converter){
return new JwtTokenStore(converter);
}
}
a.TokenConverter是读取cnsesan-jwt.jks证书文件,使用cnsesan123这个密钥对证书进行解密(关于如何生成证书,参考第一篇,用keytool工具,百度上也有一堆)
为了保险,POM文件加入这个,证书文件打包时不要重新编码
org.apache.maven.plugins
maven-resources-plugin
cert
jks
b.使用这个converter生成TokenStore,也就是后续token的存储都在这个store,使用你这个converter去加解密
3)OAuth授权服务器
@Configuration
@EnableAuthorizationServer // 开启授权服务功能
public class OAuthAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter
首先,授权服务器使用了2)中Jwt Token的相关内容,客户端密码secret使用BCryptPasswordEncoder(这个必须的,否则请求token报错,文章一没有使用加密secret,不知道是不是spring security版本问题)
@Autowired
TokenStore tokenStore;
@Autowired
JwtAccessTokenConverter jwtTokenEnhancer;
@Autowired
PasswordEncoder passwordEncoder;
@Resource(name = "MyAuthManager")
private AuthenticationManager authenticationManager; // 注入前面1)WebSecurityConfig定义的认证管理器
// 配置客户端基本信息
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("user-service")// 创建一个客户端 名字是user-service
.secret(passwordEncoder.encode("123456")) // 必须用加密存储的方式,否则401错误
.scopes("service")
.authorizedGrantTypes("refresh_token", "password") // 密码模式
.accessTokenValiditySeconds(3600); // token有效期3600s
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 定义相关tokenStore、token加解密转换器、认证管理器
endpoints.tokenStore(tokenStore).tokenEnhancer(jwtTokenEnhancer)
.authenticationManager(authenticationManager);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer
.tokenKeyAccess("permitAll()") // 允许/oauth/token被调用,默认deny,不过经测试这一个可以不加
.checkTokenAccess("permitAll()") // 允许所有检查token,默认deny,这个必须加,否则check_token不能访问显示401未授权错误
.passwordEncoder(passwordEncoder); // 定义认证服务器的client secrets密码加密方式
}
使用POSTMAN检查token:
127.0.0.1:9000/oauth/check_token?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTcxMzYzMzEsInVzZXJfbmFtZSI6InRlc3QiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDY4YWE4NGMtYTlkOS00ZjlhLWJmMzYtODNiNWU2NTQ4M2EyIiwiY2xpZW50X2lkIjoidXNlci1zZXJ2aWNlIiwic2NvcGUiOlsic2VydmljZSJdfQ.T_lbaIB9WVSredAw1bldSX7CcNhv0Y_U_jzV_IPRYfPxbuJQvCZT2qlaaVm6Y7GE8L8xXTgVm22_Gh7qO7R0Pdrw8JURYQGmmn0wmzR2AowTl2R6cy_5hGdhRt1Qk0JB1D9mu_Wpq1LJkyDD_6Hwf_F0xqPi41iRVJ2w00C-4rqm7EjriNZplmNBFIZKvLzxuo4Tj0wkkjZKmB0v5V3PJPmXrXdbWnjo1gXK_4H5Aosd0810X1d6UHyzzYPQi62bQD84kZfKpdxwM2szVtWLoDM5Cp_iy2y8QIKKPg_iM78PUg9UQQh1EQDfnO8Dg0guBLE-tgB2bVDBwRXNYB_Fzg
返回值为:
{
“exp”: 1557136331,
“user_name”: “test”,
“authorities”: [
“ROLE_USER”
],
“jti”: “068aa84c-a9d9-4f9a-bf36-83b5e65483a2”,
“client_id”: “user-service”,
“scope”: [
“service”
]
}
可见,对token实现了解密。
4)OAuth资源管理器
定义资源访问权限,哪些资源是受限访问
@Configuration
@EnableResourceServer
public class OAuthResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
TokenStore tokenStore ; // 使用JwtConfig的
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/login","/register").permitAll() // login资源是开放的
.antMatchers("/user").hasRole("USER")
.antMatchers("/admin").hasRole("ADMIN")
.antMatchers("/**").authenticated();
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.tokenStore(tokenStore);
}
}
好了,到此为止,我们已经构建了授权服务器、资源服务器想换内容,下面可以写我们的业务逻辑代码了!!
5)用户登录获取token凭证的服务UserLoginAndGetTokenSvc
首先使用ResourceOwnerPasswordResourceDetails构建一个请求凭证,向oauth/token进行请求,假如请求成功,返回token,否则返回错误OAuth2AccessDeniedException。文章一中请求token,先要自己去检查用户名 密码,不匹配抛出错误,正确的再去换取token。其实发起oauth/token请求后就自动去验证用户名密码是否匹配,文章一方式太拖沓了。我猜文章一这种方式的原因是,他使用FeignClient进行token请求,假如用户名密码不匹配,FeignClient的报错信息很不明确,原始oauth服务器的信息被丢掉了.(/oauth/token在密码用户名不匹配时抛出401而不是500,因此FeignClient不捕获??)各位看官自己可以验证。
@Service
public class UserLoginAndGetTokenSvc {
// 组装请求token凭证
private ResourceOwnerPasswordResourceDetails packPasswordResourceDetails(String clientId, String clientSecret, String username, String password, String... scopes){
ResourceOwnerPasswordResourceDetails details = new ResourceOwnerPasswordResourceDetails();
//String cryptPsw = base64Encoder.encodeToString(password.getBytes());
//设置请求认证授权的服务器的地址
details.setAccessTokenUri("http://localhost:9000/oauth/token");
//下面都是认证信息:所拥有的权限,认证的客户端,具体的用户
details.setScope(Arrays.asList(scopes));
details.setClientId(clientId);
details.setClientSecret(clientSecret);
details.setUsername(username);
details.setPassword(password);
return details;
}
public OAuth2AccessToken login(String username, String password) throws OAuth2AccessDeniedException{
String clientId = "user-service";
String clientSecret = "123456";
String[] scopes = {"service"};
ResourceOwnerPasswordAccessTokenProvider provider = new ResourceOwnerPasswordAccessTokenProvider();
OAuth2AccessToken accessToken = null;
try {
//获取AccessToken
// 1、(内部流程简介:根据上述信息,将构造一个前文一中的请求头为 "Basic Base64(username:password)" 的http请求
//2、之后将向认证授权服务器的 oauth/token 端点发送请求,试图获取AccessToken
ResourceOwnerPasswordResourceDetails details = packPasswordResourceDetails(clientId, clientSecret, username, password, scopes);
accessToken = provider.obtainAccessToken(details, new DefaultAccessTokenRequest());
}catch(OAuth2AccessDeniedException ex){
throw new OAuth2AccessDeniedException("获取jwt token出错,原因为:" + ex.getCause().getMessage());
}
return accessToken;
}
}
小知识,向oauth/token请求时,一般而言,scope、username、password、grant_type作为请求参数,request header插入:Authorization:"Basic " + Base64(clientId:clientSecret),即插入Basic 与clientId:clientSecret的Base64编码
到此,获得jwt token的服务 LoginAndGetTokenSvc 已经构造完毕,下文将介绍RestController,它使用上述服务来获得token返回。
6)Rest Controller
构造三个rest api,login负责获得token并返回,user只允许权限为ROLE_USER的用户访问,admin值允许权限为ROLE_ADMIN的用户访问。太简单了,就不解释了。
@RestController
public class Business {
@Autowired
UserLoginAndGetTokenSvc loginSvc;
@RequestMapping("/login")
public OAuth2AccessToken login(@RequestParam String username, @RequestParam String password) throws OAuth2AccessDeniedException {
OAuth2AccessToken token = loginSvc.login(username,password);
return token;
}
@RequestMapping("/user")
public String onlyUserVisit(){
return "You r USER";
}
@RequestMapping("/admin")
public String onlyAdminVisit(){
return "You r ADMIN";
}
}
7)源码下载地址如下:
https://download.csdn.net/download/xiaxuepiaopiao/11163495
设置了积分,嘻嘻,各位看官行行好,毕竟手码不容易~~~