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一个格式化了一个没有格式化。

小结

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

posted @ 2021-07-20 22:32  SnowPhoenix  阅读(187)  评论(0编辑  收藏  举报