SpringBoot学习笔记(二)
暑期加入了沃天宇老师的实验室进行暑期的实习。在正式开始工作之前,师兄先让我了解一下技术栈,需要了解的有docker、k8s、springboot、springcloud。
谨以一系列博客记录一下自己学习的笔记。更多内容见Github
2021/7/20
- 上一篇 SpringBoot学习笔记(一)https://www.cnblogs.com/SnowPhoenix/p/15008616.html
因为我并非零基础,之前有用过SpringBoot进行过很简陋的项目开发,也仔细用过其它框架(ASP.NET),所以这次的学习过程主要是明确一些之前比较模糊的东西(包括Spring和SpringBoot),所以估计是一个一个小问题的实验探究。
实验二 登录控制
这个其实不是特别实验性质,主要是我一直想要实现的一个功能——通过注解能够方便地、细粒度地按照角色来进行权限控制。
想要实现的功能
- 通过一个注解,比如叫
@RequireAuthWithRole
,可以指定某个方法需要什么样的角色才可以使用这个api(提供一个数组,只要满足其中一个即可); - 通过一个注解,比如叫
@CurrentUser
,将其标注在Controller的参数上,可以传入一个描述用户信息的对象; - 如果用户没有登陆时调用了标注了
@RequireAuthWithRole
的API,或者权限不足时,将会返回401,不执行具体方法;
实现
代码见Github:https://github.com/SnowPhoenix0105/BackEndLearning/tree/main/springboot/exp2。
找到了一篇和我期望的功能类似的博客:https://blog.csdn.net/weixin_34242819/article/details/91889372。
这篇博客的思路是,我们可以通过一个拦截器,来验证用户权限,当用户权限足够时,我们将描述用户信息的对象放到HttpServletRequest
的Attribute
中,然后构造一个参数解析器,这个解析器从Attribute
中取得描述用户信息的对象,将其绑定到特定注解描述的参数上。我们按照这个思路来做,只不过具体实现上有所差别。
简单工具类
实现描述角色的枚举类、描述用户信息的类,以及两个注解:
package top.snowphoenix.exp2.auth;
import java.util.HashMap;
public enum Role {
USER,
ADMIN
;
private static final HashMap<String, Role> strToRole = new HashMap<String, Role>() {{
put("user", Role.USER);
put("admin", Role.ADMIN);
}};
public static Role parse(String role) {
return strToRole.get(role.toLowerCase());
}
}
package top.snowphoenix.exp2.auth;
import lombok.*;
@Getter
@ToString
@Builder
public class CurrentUserInfo {
private final Role[] roles;
private final int uid;
}
package top.snowphoenix.exp2.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}
package top.snowphoenix.exp2.aop;
import top.snowphoenix.exp2.auth.Role;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequireAuthWithRole {
Role[] value() default { };
}
注意这两个注解都需要@Retention(RetentionPolicy.RUNTIME)
,表示这个注解的信息将保留到运行时,因为我们需要在运行时进行判断,所以这个注解是必要的。
参数解析器
参数解析器实现HandlerMethodArgumentResolver
接口,文档参见https://docs.spring.io/spring-framework/docs/5.2.16.RELEASE/javadoc-api/org/springframework/messaging/handler/invocation/HandlerMethodArgumentResolver.html。注意当我们实现resolveArgument
方法的时候,如果从Attribute
中取出来的是一个null
,说明我们的编码出现了严重错误(要么是拦截器写的不对,要么是一个方法没有标注@RequireAuthWithRole
但是参数标注了@CurrentUser
,在逻辑上就是需要获得用户信息但是不要求用户登录,这肯定是错误的),所以此时抛出RuntimeException
来快速crash帮助我们定位错误。
package top.snowphoenix.exp2.aop;
import lombok.var;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import top.snowphoenix.exp2.auth.CurrentUserInfo;
public class CurrentUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
return methodParameter.hasParameterAnnotation(CurrentUser.class);
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
var userInfo = (CurrentUserInfo) nativeWebRequest.getAttribute(CurrentUser.class.getSimpleName(), 0);
if (userInfo == null) {
throw new RuntimeException(
"You can only get CurrentUserInfo by @" + CurrentUser.class.getName() +
" when the method is marked with @" + RequireAuthWithRole.class.getName());
}
return userInfo;
}
}
拦截器
拦截器实现HandlerInterceptor
接口,文档参见https://docs.spring.io/spring-framework/docs/5.2.16.RELEASE/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html。HandlerInterceptor
的好处是既可以获得request
和response
这两个HTTP对象,又可以获得即将调用的目标方法,我们需要从request
获得用户的token,在鉴权失败时需要向response
中写入信息,还需要从目标方法获取其中的注解信息,这三者缺一不可,而刚好都提供给了我们。
package top.snowphoenix.exp2.aop;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import lombok.var;
import org.springframework.http.HttpStatus;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import top.snowphoenix.exp2.auth.CurrentUserInfo;
import top.snowphoenix.exp2.auth.Role;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.HashSet;
@Slf4j
public class UserValidationInterceptor implements HandlerInterceptor {
/***
*
* @param handlerMethod method info
* @return null if no {@link Role} are required, empty if any {@link Role} is ok, or the required {@link Role}s.
*/
private HashSet<Role> getRequiredRoles(HandlerMethod handlerMethod) {
HashSet<Role> ret;
RequireAuthWithRole methodAnnotation = handlerMethod.getMethod().getAnnotation(RequireAuthWithRole.class);
if (methodAnnotation != null) {
return new HashSet<Role>(Arrays.asList(methodAnnotation.value()));
}
RequireAuthWithRole classAnnotation = handlerMethod.getBeanType().getAnnotation(RequireAuthWithRole.class);
if (classAnnotation == null) {
return null;
}
return new HashSet<Role>(Arrays.asList(classAnnotation.value()));
}
private void setUnauthorized(HttpServletResponse response) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
/*
* TODO
* add `WWW-Authenticate` header
* see:
* 1. https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/401
* 2. https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/WWW-Authenticate
*/
}
private CurrentUserInfo buildCurrentUserFromToken(String token) {
JSONObject json = JSON.parseObject(token);
int uid = json.getInteger("uid");
var rolesJson = json.getJSONArray("roles");
var roles = new Role[rolesJson.size()];
for (int i = 0; i < roles.length; i++) {
roles[i] = Role.parse(rolesJson.getString(i));
}
return CurrentUserInfo.builder().uid(uid).roles(roles).build();
}
private boolean hasAuth(Role[] userRoles, HashSet<Role> requiredRoles) {
if (requiredRoles == null || requiredRoles.isEmpty()) {
return true;
}
for (Role userRole : userRoles) {
if (requiredRoles.contains(userRole)) {
return true;
}
}
return false;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
var requiredRoles = getRequiredRoles(handlerMethod);
if (requiredRoles == null) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null) {
setUnauthorized(response);
return false;
}
CurrentUserInfo userInfo;
try {
userInfo = buildCurrentUserFromToken(token);
}
catch (Exception e) {
setUnauthorized(response);
log.warn("build userInfo from token "" + token + "" fail: ", e);
return false;
}
if (!hasAuth(userInfo.getRoles(), requiredRoles)) {
setUnauthorized(response);
return false;
}
request.setAttribute(CurrentUser.class.getSimpleName(), userInfo);
return true;
}
}
方法getRequiredRoles
用于从调用目标的注解中获取权限需求。这里当从方法中没有找到注解,会向上搜索方法所属的类中的注解。如果在类上标注了@RequireAuthWithRole
,那么当方法没有标注该注解的时候,类上的注解会作为默认选项。
方法setUnauthorized
用来在权限不足时对用户进行响应,这里仅仅将状态码设置为401
,不过也可以进行重定向到登录页面这样的操作。后面留了一个TODO,是因为MDN
中描述,如果返回401
,应当在WWW-Authenticate
头中指定如何进行验证的信息。参考https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/401、https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/WWW-Authenticate。
方法buildCurrentUserFromToken
用于通过token获取用户信息。这里我是模拟了使用JWT的情景(实际上并没有使用,因为没有加密解密的过程),这种情境下,我们可以往token中存储较多的信息,比如用户的角色信息,所以我们通过token直接获得用户的角色信息,而不是数据库。
方法hasAuth
用于通过用户的角色和api所需角色进行对比,判断用户是否具有权限。如果api不需要任何角色,只需要登录,那么直接为true,否则,只要这两个集合的交集非空,则用户具有访问该api的权限。
注册参数解析器和拦截器
没什么好说的,不要忘记@Configuration
。
package top.snowphoenix.exp2.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import top.snowphoenix.exp2.aop.CurrentUserHandlerMethodArgumentResolver;
import top.snowphoenix.exp2.aop.UserValidationInterceptor;
import java.util.List;
@Configuration
public class UserValidationConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry
.addInterceptor(new UserValidationInterceptor())
.addPathPatterns("/**")
;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers
.add(new CurrentUserHandlerMethodArgumentResolver())
;
}
}
控制器Controller
这里实现了两个api:
- GET方法,只需要登录,没有角色要求;
- POST方法,需要
ADMIN
角色;
两个接口都返回当前用户信息,并且返回用户给定的输入。
package top.snowphoenix.exp2.controllers;
import com.alibaba.fastjson.JSONObject;
import lombok.var;
import org.springframework.web.bind.annotation.*;
import top.snowphoenix.exp2.aop.CurrentUser;
import top.snowphoenix.exp2.aop.RequireAuthWithRole;
import top.snowphoenix.exp2.auth.CurrentUserInfo;
import top.snowphoenix.exp2.auth.Role;
@RestController
@RequestMapping("/api")
public class ApiController {
@RequireAuthWithRole()
@GetMapping("/echo/{content}")
public String echoGet(@CurrentUser CurrentUserInfo user, @PathVariable String content) {
var json = new JSONObject();
json.put("user", user);
json.put("meg", content);
return json.toJSONString();
}
@RequireAuthWithRole({Role.ADMIN})
@PostMapping("/echo")
public String echoPost(@CurrentUser CurrentUserInfo user, @RequestBody String content) {
var json = new JSONObject();
json.put("user", user);
json.put("meg", content);
return json.toJSONString();
}
}
测试
见http文件夹下的echo.http
,使用IDEA自带的HTTP Client
来进行测试。分别测试了以下项目:
- POST方法已登录且角色正确
- POST方法已登录但角色不正确
- POST方法未登录
- GET方法已登录
- GET方法未登录
POST http://localhost:8081/api/echo
Content-Type: text/plain
Authorization: {"uid":123, "roles":["User", "Admin"]}
withAdminAuth
###
POST http://localhost:8081/api/echo
Content-Type: text/plain
Authorization: {"uid":123, "roles":["User"]}
withUserAuth
###
POST http://localhost:8081/api/echo
Content-Type: text/plain
hello
###
GET http://localhost:8081/api/echo/withAuth
Accept: application/json
Authorization: {"uid":123, "roles":["User"]}
###
GET http://localhost:8081/api/echo/hello
Accept: application/json
###
得到的结果为:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 67
Date: Tue, 20 Jul 2021 14:17:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
> 2021-07-20T221708.200.txt
Response code: 200; Time: 195ms; Content length: 67 bytes
HTTP/1.1 401
Content-Length: 0
Date: Tue, 20 Jul 2021 14:17:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
<Response body is empty>
Response code: 401; Time: 82ms; Content length: 0 bytes
HTTP/1.1 401
Content-Length: 0
Date: Tue, 20 Jul 2021 14:17:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
<Response body is empty>
Response code: 401; Time: 30ms; Content length: 0 bytes
HTTP/1.1 200
Content-Type: application/json
Content-Length: 54
Date: Tue, 20 Jul 2021 14:17:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
> 2021-07-20T221708.200.json
Response code: 200; Time: 17ms; Content length: 54 bytes
HTTP/1.1 401
Content-Length: 0
Date: Tue, 20 Jul 2021 14:17:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
<Response body is empty>
Response code: 401; Time: 13ms; Content length: 0 bytes
可见1、4成功了,其余的都401了,符合预期。
1中返回的内容为:
{"user":{"roles":["USER","ADMIN"],"uid":123},"meg":"withAdminAuth"}
4中返回的内容为:
{
"user": {
"roles": [
"USER"
],
"uid": 123
},
"meg": "withAuth"
}
用postman试了一下,4应该返回的没有空格回车啥的,不知道为啥IDEA一个格式化了一个没有格式化。
小结
本次实验,我们通过注解、拦截器、参数解析器,实现了通过注解来进行细粒度的权限控制。