本文实现了OAuth2的资源服务器(ResourceServer),在Controller的方法上进行基于注解的权限控制,对4个权限控制注解(@PreAuthorize、@PostAuthorize、@PreFilter、@PostFilter)进行了测试,增加了Feign对OAuth2的支持。
1、源代码
Github地址: https://github.com/xiaoboey/from-zero-to-n/tree/master/two ,Spring Boot的版本是2.3.3,Spring Cloud的版本是Hoxton.SR8。
如果有一定的Spring Boot和Spring Cloud基础,建议Clone代码,运行起来再结合代码来理解。
2、扩展service-one为资源服务器(Resource Server)
在pom.xml文件中增加依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
在application.yml文件中添加OAuth2的配置:
security:
oauth2:
client:
access-token-uri: http:
grant-type: client_credentials, refresh_token, password
scope: server
新增配置类JwtConfig.java,主要是配置公钥:
@Configuration
public class JwtConfig {
@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter);
}
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
Resource resource = new ClassPathResource("oauth2-jwt.cert");
String publicKey;
try {
publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
converter.setVerifierKey(publicKey);
return converter;
}
}
增加资源服务器的配置(ResourceServerConfig.java),除了指定/login等可以任意访问,重点是EnableGlobalMethodSecurity(prePostEnabled = true),表示在Controller的方法上使用注解进行访问控制:
@Configuration
@EnableOAuth2Client
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/actuator/**", "/hello", "/getPort", "/login").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.formLogin().disable()
.logout().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore);
}
}
增加IAuthServerFeignClient.java,这是调用auth-server/oauth/token的FeignClient:
@FeignClient(value = "auth-server")
public interface IAuthServerFeignClient {
@PostMapping("/oauth/token")
String getToken(@RequestHeader(value = "Authorization") String authorization,
@RequestParam("grant_type") String grantType,
@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "refresh_token", required = false) String refreshToken);
@PostMapping("/oauth/check_token")
String checkToken(@RequestHeader(value = "Authorization") String authorization,
@RequestParam("token") String token);
}
修改OneController,增加登录(login)方法,结合IAuthServerFeignClient也就是上文用Postman获取访问令牌的代码实现(这里只实现password方式):
@RequestMapping("/login")
public String login(@RequestParam String username, @RequestParam String password) {
String basic = String.format("%1$s:%2$s", clientName, "123456");
String authorization = null;
try {
authorization = "Basic " + Base64.getEncoder().encodeToString(basic.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return authServerFeignClient.getToken(authorization, "password", username, password, null);
}
用Postman测试一下登录:
3、在Controller上进行权限控制
登录成功后,拿到的access_token就是Base64编码的JWT令牌,后续访问微服务的资源时就在请求的Header里带上这个令牌,资源服务器(ResourceServer)根据这个令牌来做访问控制。
在资源服务器的配置里我们用@EnableGlobalMethodSecurity(prePostEnabled = true)开启了Controller上的权限控制,然后就可以在Controller的方法上使用注解来控制对该方法的访问。
我们先来看一个例子,在OneController中增加一个获取用户信息的方法,这个用户是当前用户:
@PreAuthorize("isAuthenticated()")
@RequestMapping("/getUserInfo")
public String getUserInfo(Principal principal) {
String userName = principal.getName();
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode node = objectMapper.createObjectNode();
node.put("name", userName);
node.put("mobile", "13812345678");
return node.toString();
}
用Postman测试一下:
Bearer Token是在Header中传递令牌,KEY=Authorization,VALUE以“Bearer ”开头:
把Authoriztion改为No Auth,即不在Header中传递access_token,返回的结果是未授权:
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
4、权限控制表达式
对应@EnableGlobalMethodSecurity(prePostEnabled = true),可以在Controller方法上使用的权限控制注解还有PostAuthorize、PostFilter和PreFilter,这里先说一下这几个注解的表达式中可以用的权限控制相关的变量和函数(access-control expression):
hasRole([ROLE]),当前用户关联了指定的角色才能访问;(注意角色的名称必须以ROLE_为前缀,否则视为权限)
hasAnyRole([ROLE1,ROLE2]),当前用户关联了指定角色之一就可以访问;
hasAuthority([AUTH]),当前用户关联了指定的权限才能访问;(使用JWT的话,建议不用权限,只用角色,角色的粒度粗一些,可以减少JWT的长度,少消耗一些带宽)
hasAnyAuthority([AUTH1,AUTH2]),当前用户关联了指定权限之一就可以访问;
permitAll(),总是返回true,表示允许任意访问;(经过实测,在我们的微服务中无效,permitAll的配置要放到ResourceServerConfig.java里去 )
denyAll(),拒绝所有访问,与permitAll相反;
isAnonymous(),当前用户是否匿名用户;
isRememberMe(),当前用户是否是通过Remember-Me自动登录的;
isAuthenticated(),当前用户是否已经登录(包括Remember-Me自动登录);
isFullyAuthenticated(),当前用户是否已经登录(不包括Remember-Me自动登录);
principal,代表当前用户的principal对象;
authentication,从SecurityContext获取的当前Authentication对象;
returnObject,方法的返回值,只在PostAuthorize和PostFilter中有限;
filterObject,在使用PreFilter和PreFilter过滤时,表示要过滤的集合(Collection)中的对象。
实际使用时,大家可以结合Spring EL和上面列出来的这些变量和函数进行表达式的编写。
5、自定义权限控制
这里的自定义权限控制,是在Spring EL上做文章
先实现定义访问控制的处理逻辑:
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
@Component
public class CustomAccessControl {
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
}
}
然后通过注解使用:
@Controller
@RequestMapping("/some")
public class SomeController {
@PreAuthorize("@customAccessControl.hasPermission(#request, #authentication)")
@PostMapping(value = "/someoperate")
@ResponseBody
public String someoperate(@RequestParam String someparam,
HttpServletRequest request,
Authentication authentication) {
}
}
注:这个自定义的方式提供了一种可能,但不推荐使用,它会把访问控制的逻辑分散到各个微服务内部,跟基于JSON Web Token的OAuth2权限机制是相违背的。
6、访问控制:PreAuthorize和PostAuthorize
PreAuthorize的测试,我们再增加一个只有ADMIN才可以访问的方法(获取用户清单):
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/listUserInfo")
public String listUserInfo() {
ObjectMapper objectMapper = new ObjectMapper();
ArrayNode nodes = objectMapper.createArrayNode();
ObjectNode node = nodes.addObject();
node.put("name", "first");
node.put("mobile", "13811225678");
node = nodes.addObject();
node.put("name", "second");
node.put("mobile", "13822335678");
return nodes.toString();
}
还记得数据库初始化时的worker和admin这两个用户吗?大家可以分别用这两个用户来测试一下对listUserInfo的访问
在OneController中增加一个方法测试PostAuthorize(这种情况其实比较少见,测试代码模拟了一个忘记密码通过邮件找回的处理逻辑)
@PostAuthorize("returnObject != null")
@RequestMapping("/forgotPassword")
public String forgotPassword(@RequestParam(required = false) String mail) {
boolean valid = "abc@some.com".equals(mail);
if (valid) {
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode node = objectMapper.createObjectNode();
node.put("mail", mail);
node.put("new_password", "123456");
return node.toString();
} else {
return null;
}
}
大家可以实测一下对forgotPassword的调用,会发现不登录不能调用,只有登录用户并传递正确的mail参数,才能返回new_password。(所以忘记密码想通过它找回行不通^_^)
测试forgotPassword
7、数据过滤:PostFilter和PreFilter
这两个的使用场景很少,大家可以了解一下,略过亦可。
测试PostFilter,在OneController.java里添加listBook方法,使用@PostFilter过滤返回结果,只要author是金庸的书籍:
@PostFilter(value = "filterObject.get('author').asText().equals('金庸')")
@RequestMapping("/listBook")
public List<ObjectNode> listBook() {
List<ObjectNode> nodeList = new ArrayList<>();
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode node = objectMapper.createObjectNode();
node.put("name", "《飞狐外传》");
node.put("author", "金庸");
nodeList.add(node);
node = objectMapper.createObjectNode();
node.put("name", "《武林外史》");
node.put("author", "古龙");
nodeList.add(node);
return nodeList;
}
用Postman测试listBook
测试PreFilter,添加listGoods方法,进行输入参数过滤,只取大于0的id:
@PreFilter(filterTarget = "ids", value = "filterObject > 0")
@RequestMapping("/listGoods")
public String listGoods(@RequestParam(value = "ids[]") List<Integer> ids) {
return String.join(",", ids.stream().map(p -> String.valueOf(p)).collect(Collectors.toList()));
}
用Postman测试listGoods
8、扩展service-two为资源服务器
把service-two扩展为OAuth2的资源服务器(ResourceServer),使得service-two向外暴露的服务也受到OAuth2安全框架的保护。
扩展为资源服务器的代码我就不贴了,跟service-one类似,大家直接看github上的代码。
从代码上看service-two比service-one还简单,增加了OAuth2的Maven依赖和JWT、ResourceServer的配置,然后增加UserController.java进行用户相关的操作,测试访问是否受控。
@RestController
@RequestMapping("/user")
public class UserController {
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/delete")
public String delete(@RequestParam String name) {
return String.format("User $s deleted.", name);
}
}
先用worker用户的access_token测试一下:
用Postman测试user/delete
再用admin用户的access_token测试:
可见service-two在扩展为资源服务器后,没有重复做登录(login)的工作,也通过OAuth2安全框架把Controller上暴露出去的方法(资源)保护了起来。
9、OAuth2和Feign
接下来我们在service-two的TwoController.java里添加新的方法,测试一下服务间的调用是否受OAuth2的保护:
@PreAuthorize("isAuthenticated() || isAnonymous()")
@RequestMapping("/listUserInfo")
public String listUserInfo() {
return serviceOneFeignClient.listUserInfo();
}
IServiceOneFeignClient.java
@Qualifier("serviceOneFeignClient")
@FeignClient(value = "service-one", fallback = ServiceOneFallback.class)
public interface IServiceOneFeignClient {
@PostMapping("/hello")
String hello();
@PostMapping("/getPort")
Integer getPort();
@RequestMapping("/listUserInfo")
String listUserInfo();
}
ServiceOneFallback.java
@Component
public class ServiceOneFallback implements IServiceOneFeignClient {
@Override
public String hello() {
return "error";
}
@Override
public Integer getPort() {
return Integer.valueOf(-1);
}
@Override
public String listUserInfo() {
return "[]";
}
}
通过测试,会发现Feign进行服务间的调用时没有把外部请求的JWT令牌带上,从而导致feign-test/listUserInfo失败。为了让Feign把令牌带上,需要增加一个RequestInterceptor,拦截请求并加上Header:
@Component
public class FeignRequestInterceptor implements RequestInterceptor {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void apply(RequestTemplate template) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
String values = request.getHeader(name);
template.header(name, values);
}
logger.info("Feign interceptor header: {}", template);
}
}
}
对应的IServiceOneFeignClient.java也要调整:
@FeignClient(value = "service-one", configuration = FeignRequestInterceptor.class, fallback = ServiceOneFallback.class)
10、关闭auth-server有什么影响?
到这里,集成OAuth2和JWT的工作已经差不多了,我们再来看一看图:
回顾一下,service-one实现了登录(/login),客户端使用账号密码访问service-one/login
获取到access_token(也就是JWT令牌),然后用这个JWT去访问service-one和service-two暴露的方法(资源),那些加了权限控制注解的方法(@PreAuthorize等)都按我们预计的方式受到了保护,只有授权的用户(角色)才能访问。
用Feign做内部服务调用,在增加了RequestInterceptor带上JWT令牌后,也可以顺利工作。
我们现在验证一下JWT的优势,即自带数据,资源服务器鉴权时不需要跟认证中心交换,直接用JWT上带的信息做权限控制。比较简单的办法就是把auth-server的实例都停了,再试试用feign-test/listUserInfo,调用结果如下:
[{"name":"first","mobile":"13811225678"},{"name":"second","mobile":"13822335678"}]
可见资源服务器(service-one和service-two)在接受到资源请求进行权限控制时,没有跟auth-server交互数据。OAuth2结合JWT,在大量细粒度微服务的分布式部署环境下,比较好地进行了资源的访问控制。
上一篇: Spring Cloud 微服务实践 (5) - 认证中心
下一篇: Spring Cloud 微服务实践 (7) - 日志
评论