zoukankan      html  css  js  c++  java
  • SpringBoot学习笔记(二)

    SpringBoot学习笔记(二)

    暑期加入了沃天宇老师的实验室进行暑期的实习。在正式开始工作之前,师兄先让我了解一下技术栈,需要了解的有docker、k8s、springboot、springcloud。

    谨以一系列博客记录一下自己学习的笔记。更多内容见Github

    2021/7/20

    因为我并非零基础,之前有用过SpringBoot进行过很简陋的项目开发,也仔细用过其它框架(ASP.NET),所以这次的学习过程主要是明确一些之前比较模糊的东西(包括Spring和SpringBoot),所以估计是一个一个小问题的实验探究。

    实验二 登录控制

    这个其实不是特别实验性质,主要是我一直想要实现的一个功能——通过注解能够方便地、细粒度地按照角色来进行权限控制。

    想要实现的功能

    1. 通过一个注解,比如叫@RequireAuthWithRole,可以指定某个方法需要什么样的角色才可以使用这个api(提供一个数组,只要满足其中一个即可);
    2. 通过一个注解,比如叫@CurrentUser,将其标注在Controller的参数上,可以传入一个描述用户信息的对象;
    3. 如果用户没有登陆时调用了标注了@RequireAuthWithRole的API,或者权限不足时,将会返回401,不执行具体方法;

    实现

    代码见Github:https://github.com/SnowPhoenix0105/BackEndLearning/tree/main/springboot/exp2

    找到了一篇和我期望的功能类似的博客:https://blog.csdn.net/weixin_34242819/article/details/91889372

    这篇博客的思路是,我们可以通过一个拦截器,来验证用户权限,当用户权限足够时,我们将描述用户信息的对象放到HttpServletRequestAttribute中,然后构造一个参数解析器,这个解析器从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.htmlHandlerInterceptor的好处是既可以获得requestresponse这两个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/401https://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:

    1. GET方法,只需要登录,没有角色要求;
    2. 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来进行测试。分别测试了以下项目:

    1. POST方法已登录且角色正确
    2. POST方法已登录但角色不正确
    3. POST方法未登录
    4. GET方法已登录
    5. 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一个格式化了一个没有格式化。

    小结

    本次实验,我们通过注解、拦截器、参数解析器,实现了通过注解来进行细粒度的权限控制。

  • 相关阅读:
    用故事说透 HTTPS
    nginx部署基于http负载均衡器
    Jenkins使用docker-maven-plugin进行编译时发现没有权限
    Jenkins执行mvn -f ${project_name} clean package报错:找不到父工程
    Harbor的镜像上传和拉取
    java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
    Jenkins+SonarQube代码审查
    Centos7安装SonarQube7.9.3
    Centos7 rpm 安装Mysql5.7
    Jenkins 配置邮箱服务器发送构建结果
  • 原文地址:https://www.cnblogs.com/SnowPhoenix/p/15037420.html
Copyright © 2011-2022 走看看