代码:https://github.com/betterGa/ChangGou
一、资源服务器授权配置
基本上所有微服务都是资源服务。
(1)用户微服务
首先,认证服务使用私钥,采用非对称加密算法 生成令牌,资源服务使用公钥 来校验令牌的合法性。 需要配置公钥,并将公钥拷贝到 public.key 文件中,将此文件拷贝到每一个需要的资源服务工程的 classpath 下 ,比如 用户微服务:
解析令牌需要添加依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
接下来,需要配置每个系统的 Http 请求路径安全控制策略 以及 读取公钥信息识别令牌,对于用户微服务,新建 config 包,提供配置类,@EnableResourceServer 注解用于开启资源校验服务,进行令牌的校验;@EnableGlobalMethodSecurity 注解是全局方法校验:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "public.key";
/***
* 定义 JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/***
* 定义 JJwtAccessTokenConverter
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
/***
* Http 安全配置,对每个到达系统的 http 请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
// 所有请求必须认证通过
http.authorizeRequests()
// 下边的路径放行
.antMatchers(
"/user/add"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
用户每次访问微服务的时候,需要先申请令牌,令牌申请后,每次将令牌放到 Http headers 中,才能访问微服务。Http headers 中每次 需要 添加一个 Authorization 头信息,头的值为 bearer token("bearer"空格 [token])。
比如,先不携带令牌测试,访问 http://localhost:18087 /user ,不携带令牌,结果如下(因为这时网关获取不到令牌,所以响应 401 错误状态码):
携带正确令牌访问(这时网关可以获取到令牌信息,予以放行):
而且对于 configure 方法中设置的对 user/add 路径放行,可以看到,不需要在 headers 中传 Authorization 参数,确实放行了:
(2)网关微服务
接下来,需要让 OAuthorization 对接网关微服务。用户每次访问微服务的时候,先去 OAuth2.0 服务登录,登录后再访问微服务网关,微服务网关将请求转发给其他微服务处理。
在上一篇博客,搭建了 changgou-user-oauth 微服务,实现了 9001/user/login 的登录功能,在上上一篇博客中,搭建了 changgou-gateway-web 微服务,提供了 AuthorizeFilter 全局过滤器,总的来说,实现了以下功能:
(1)用户登录成功(也就是用户名和密码正确)后,会将令牌信息存入到 cookie 中
(2)用户携带 Cookie 中的令牌 访问微服务网关
(3)微服务网关先获取头文件中的令牌信息,如果 HTTP Headers 中没有 Authorization 令牌信息,则到参数中找,参数中如果没有,则到 Cookie 中找,最后将令牌信息封装到 HTTP Headers ,并调用其他微服务
(4)其他微服务会获取 HTTP Headers 中的 Authorization 令牌信息,然后匹配令牌数据是否能使用公钥解密,如果解密成功说明用户已登录,解密失败,说明用户未登录
修改 changgou-gateway-web 的全局过滤器 AuthorizeFilter 类的逻辑,如果令牌不在 Http Headers 头文件中,需要将 bearer 令牌信息添加到头文件中,而且,现在的令牌是经过私钥生成的,所以是需要用公钥验证,之前的解析JWT的代码不能用啦,先注释掉:
这时访问 http://localhost:8001/api/user ,将通过私钥生成的新令牌放到头文件中,在令牌前面添加 Bearer 令牌:
这里 全局过滤器 AuthorizeFilter的逻辑是,从 Http Headers 中获取到了 Authorization 参数,所以就执行了 chain.filter(exchange)
放行,后续是需要把解析令牌的方法重新提供的。
而且在 AuthorizeFilter 类中,是依次从头文件、参数、Cookie 中获取 Authorization 参数的,所以一次传过 Authorization 参数,没有清空 Cookie 且未过期的话,后续就不需要传了;或者通过传参数的形式,是不需要加 "bearer " 前缀的,因为代码逻辑里会把 token 取出,再加上"bearer " 前缀放到 Http Headers 中:
二、SpringSecurity 权限控制
由于我们项目使用了微服务,任何用户都有可能使用任意微服务,此时我们需要控制相关权限,例如:普通用户角色不能使用用户的删除操作,只有管理员才可以使用,那么这个时候就需要使用到 SpringSecurity 的权限控制功能了。
1、角色加载
在 changgou-user-oauth 服务中,UserDetailsServiceImpl 类实现了加载用户相关信息:
可以看到,给登录用户定义了三个角色,分别为 salesman,accountant,user,目前使用的是硬编码方式将角色写 si 了,后面会从数据库加载。
2、角色权限控制
在每个微服务中,需要获取用户的角色,然后根据角色识别是否允许操作指定的方法,Spring Security 中定义了四个支持权限控制的表达式注解,分别是@PreAuthorize、@PostAuthorize、@PreFilter 和 @PostFilter。其中前两者可以用来在方法调用前或者调用后进行权限检查,后两者可以用来对集合类型的参数或者返回值进行过滤。在需要控制权限的方法上,我们可以添加 @PreAuthorize注解,用于方法执行前进行权限检查,校验用户当前角色是否能访问该方法。
(1)开启 @PreAuthorize
之前,在 changgou-user-service 的 ResourceServerConfig 类上 添加了 @EnableGlobalMethodSecurity 注解,用于开启 @PreAuthorize 的支持:
(2)方法权限控制
在 UserController 类的 findAll() 方法上添加权限控制注解@PreAuthorize:
(3)测试
我们使用 Postman 测试,先创建令牌,然后将 bearer 令牌存放到 Http headers 中,访问 http://localhost:8001/api/user:
可以看到,发现上面无法访问,因为用户登录的时候,角色不包含 user 角色,所以被拦截了。
如果希望一个方法能被多个角色访问,在方法上配置 @PreAuthorize("hasAnyAuthority('admin','user')")
。如果希望一个类都能被多个角色访问,在类置@PreAuthorize("hasAnyAuthority('admin','user')")
。
三、OAuth 动态加载数据
前面 OAuth 我们用的数据都是静态,写 si 的:
- 客户端数据 [生成令牌相关数据]
- 用户登录账号密码
在现实工作中,数据应该是从数据库加载的,所以我们需要调整一下 OAuth 服务。
在 changgou-user-oauth 工程中的 config 包的 AuthorizationServerConfig 类中进行客户端配置,从 数据库中取:
看看 JdbcClientDetailsService 源码:
可以看到,都是对 oauth_client_details 表执行 SQL 语句,所以需要在数据库中提供一个 oauth_client_details 表,用于记录客户端相关信息:
上述表结构属于 SpringSecurity Oauth2.0 所需的一个认证表结构,不能随意更改。插入两条记录:
INSERT INTO `oauth_client_details` VALUES ('changgou', null, '$2a$10$wZRCFgWnwABfE60igAkBPeuGFuzk74V2jw3/trkdUZpnteCtJ9p9m', 'app', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost', null, '432000000', '432000000', null, null);
INSERT INTO `oauth_client_details` VALUES ('szitheima', null, '$2a$10$igxoCZxTbjWx5TrmfWEEpe/WFdwbUhbxik9BKTe9i64ZOSfnu/lqe', 'app', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost', null, '432000000', '432000000', null, null);
之前的 AuthorizationServerConfig 类中,程序里是写 si 的:
现在需要改成从数据库中加载用户信息:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource).clients(clientDetails());
}
还需要把 changgou-user-oauth 工程中,config 包下的 UserDetailsServiceImpl 自定义授权 类中, loadUserByUsername 方法的逻辑改成从数据库中加载:
,
可以看到,这时密钥不需要加密处理了,因为是从数据库中查询出来的,已经加密过了。
而且点进紫框里的 clientDatilsService(这个 对象是通过 @Autowired ClientDetailsService clientDetailsService; ·
自动装配的),可以看到:
和 AuthorizationServerConfig 中的 ClientDatilsService 是一个类:
也就是说, 这个 AuthorizationServerConfig 的 clientDetails() 方法创建的 从数据库中加载来的 ClientDatailsService 对象,会被 UserDetailsServiceImpl 通过 @Autowired 注解自动装配识别到,而这个对象是会到数据库中加载 oauth_client_details 表 的。
上一篇博客中,实现了访问 changgou-service-user 用户微服务需要令牌认证,所以,接下来想要既认证客户端 id 和密钥,又认证数据库表中的用户名和密码,就需要通过 changgou-user-oauth 认证微服务去访问用户微服务,进而得到用户的信息,可是这时用户还没登陆呢,所以是没有令牌的,理应是被禁止访问的,我们先对 findById 方法进行放行,不用令牌也能访问。
先加个路径:
进行放行:
再给 OAuthApplication 类上开启 feign:
后续就可以进行 feign 调用啦,在 service-api 中提供 feign :
@FeignClient(value = "user")
@RequestMapping(value = "/user")
public interface UserFeign{
/**
* 查询用户信息
* @param id
* @return
*/
@GetMapping({"/load/{id}"})
Result<User> findById(@PathVariable String id);
}
就可以在 UserDetailsServiceImpl 类中进行 user 的 feign 调用,整个 UserDetailsServiceImpl 的代码如下:
/*****
* 自定义授权认证类
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
ClientDetailsService clientDetailsService;
@Autowired
UserFeign userFeign;
/****
* 自定义授权认证
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
/***
* 客户端信息认证
*/
// (3)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// (4)
if (authentication == null) {
// (1)
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
if (clientDetails != null) {
//秘钥
String clientSecret = clientDetails.getClientSecret();
/* 静态方式*/
//return new User(username, // 客户端 ID
// new BCryptPasswordEncoder().encode(clientSecret), // 客户端密钥
// AuthorityUtils.commaSeparatedStringToAuthorityList(""));
// (2)
return new User(username, // 客户端 ID
clientSecret, // 客户端密钥
AuthorityUtils.commaSeparatedStringToAuthorityList(""));
}
}
/***
* 用户账号密码信息认证
*/
if (StringUtils.isEmpty(username)) {
return null;
}
// (5) 从数据库加载用户信息
Result<pojo.User> userResult = userFeign.findById(username);
if (userResult == null || userResult.getData() == null) {
return null;
}
// 获取用户名对应的密码
String password = userResult.getData().getPassword();
// String pwd = new BCryptPasswordEncoder().encode(password);
// 指定用户角色信息
String permissions = "salesman,accountant,user";
UserJwt userDetails = new UserJwt(username, password, AuthorityUtils
.commaSeparatedStringToAuthorityList(permissions));
return userDetails;
}
}
分析一下这里的逻辑:访问 http://localhost:9001/user/login?username=szitheima&password=szitheima 路径,是先执行的 UserLoginController 控制类的 login 方法,这个方法是从 application.yml 的 auth:clientId 属性里拿到的 username “changgou”,刚开始,(4)是 null 的,然后会执行(1),(1)会把这个 username 传递给 loadUserByUsername 方法,先进行客户端信息认证,会先到 clientDetailsService.loadClientByClientId
方法,这个前面提到了,实现类是 JdbcClientDetailsService ,所以是会到数据库的 oauth_client_details 表中查询 client_id, client_secret, scope 等信息(不是从 application.yml 中得到的喔),把这些信息作为 ClientDetails 对象的属性,因为 clientid 是 changgou,所以查到的都是 changgou 对应的信息。不过 (2)处,并不是直接返回 new 的 User 对象,会去执行 DaoAuthenticationProvider 类的 retrieveUser 方法、ProviderManager类的 authenticate 方法、BasicAuthenticationFilter 类的 doFilterInternal 方法(这个方法里,username 不是客户端的那个 “changgou” 了,而是 login 路径里的 username 参数 “szitheima”),然后又会跳回 (3)处重新执行一遍,这时 (4)就不是 null 了,会到(5),加载数据库中的信息,这时的 username 是 szitheima,然后用查询到的 username, password, permissions 信息,作为 JWT 的载荷,返回结果。
(代码没细研究,不过感觉有点儿 Spring bean 一二级缓存的那个意思,暂时没想到为什么这么设计,有待细究。)
接下来进行用户账号信息认证,通过用户名查询用户记录,并把用户名、密码作为生成令牌的依据,生成令牌,运行效果:
(可以把生成的令牌给到 testParseToken() 方法,进行解析,运行结果:
可以看到,封装了用户名、角色等信息。以后自己需要生成令牌时,可以参考这个令牌中的参数。)
doFilterInternal 方法 源码:
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// (1)
boolean debug = this.logger.isDebugEnabled();
String header = request.getHeader("Authorization");
if (header != null && header.toLowerCase().startsWith("basic ")) {
try {
String[] tokens = this.extractAndDecodeHeader(header, request);
assert tokens.length == 2;
String username = tokens[0];
if (debug) {
this.logger.debug("Basic Authentication Authorization header found for user '" + username + "'");
}
if (this.authenticationIsRequired(username)) {
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, tokens[1]);
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
Authentication authResult = this.authenticationManager.authenticate(authRequest);
if (debug) {
this.logger.debug("Authentication success: " + authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
this.rememberMeServices.loginSuccess(request, response, authResult);
this.onSuccessfulAuthentication(request, response, authResult);
}
} catch (AuthenticationException var10) {
SecurityContextHolder.clearContext();
if (debug) {
this.logger.debug("Authentication request for failed: " + var10);
}
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var10);
if (this.ignoreFailure) {
chain.doFilter(request, response);
} else {
this.authenticationEntryPoint.commence(request, response, var10);
}
return;
}
chain.doFilter(request, response);
} else {
chain.doFilter(request, response);
}
}
四、总结
(1) 基本上所有微服务都是资源服务。
资源服务,比如 user 工程,使用公钥来验证令牌,需要在 resources 下提供 public.key,导入 spring-cloud-starter-oauth2
依赖,提供使用 @Configuration 声明配置类,并在配置类上使用 @EnableResourceServer 开启资源服务。并在其中提供生成 TokenStore、JwtAccessTokenConverter、publicKey 的方法,以及在 configure(HttpSecurity)方法中设置请求需要通过认证、可以放行的路径。所以,访问 user 工程对应的 18087 端口,需要在 headers 中提供 key 为 Authorization,value 为 bearer token 的参数。
(2)使用 Spring Security 权限控制:
需要先在实现了 UserDetailsService 的类中,指定角色,比如 “salesman,accountant,user”,覆写 loadUserByUsername 方法,返回值为 UserDetails,把角色封装在 UserDetails 对象中。
比如,findAll 方法仅限 user 角色访问,需要在 findAll 方法上使用 @PreAuthorize(“hasRole(‘user’)”)注解,并在相应的启动类上使用 @EnableGlobalMethodSecurity 注解开启对 @PreAuthorize 的支持。
(3)使用 SpringSecurity Oauth2.0 从数据库中动态加载用户数据。
首先需要在数据库中提供 oauth_client_details 认证表,包含客户端 ID、客户端密钥、令牌有效期等字段,表结构不能随意更改。
在继承了 AuthorizationServerConfigurerAdapter 的配置类中提供:
@Bean
public ClientDetailsService clientDetails() {
return new JdbcClientDetailsService(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource).clients(clientDetails());
}
在实现了 UserDetailsService 接口的 Service 类中,loadUserByUsername 逻辑需要改成从数据库中加载,并且在这个方法里进行用户信息的认证,即 通过 Feign 调用 user 工程里的 findById 方法,通过 username 这个 ID,查询用户信息,获取到用户名对应的密码,将用户名、密码、角色 作为载荷,
生成 JWT 返回 。
🎉 bearer token
这是 RFC 6750 的链接:https://tools.ietf.org/html/rfc6750,是对 bearer token 的规范。定义是这样的:
bearer token 是一种安全令牌,具有以下属性:拥有 bearer token 的任何一方(被称为 “bearer”),可以以任何方式,和 同样持有它的任何一方 一样地使用它 来访问受 OAuth 2.0 保护的资源(但是不能 也不需要证明 bearer 有加密用的密钥),为了保护 bearer token 不被误用,需要保证它在存储和传输过程中不被泄露。bearer 认证方案主要用于使用了 WWW-Authenticate 和 Authorization HTTP headers 的服务。
我的理解是 bearer token 就像我们用的百度网盘,不需要知道网盘加密算法,只需要提取码就可以访问资源。
在 user 微服务中,ResourceServerConfig 类 继承了ResourceServerConfigurerAdapter 类,源码如下:
有两个 configure 方法,第一个的参数与资源安全配置相关,第二个与 http 安全配置相关。
在这里设置了 TokenExtractor 默认的实现—-BearerTokenExtractor,所以,访问用户微服务时,Http headers 的 Authorization 参数里的 "bearer " 是必不可少的。