V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
tomsun28
V2EX  ›  Java

restful api 权限设计 - 快速搭建完整认证鉴权项目,是真的只要 10 分钟

  •  
  •   tomsun28 ·
    tomsun28 · 2021-01-25 14:43:45 +08:00 · 2967 次点击
    这是一个创建于 1398 天前的主题,其中的信息可能已经有所发展或是发生改变。

    只要 10 分钟是真的,我记时了哦。
    现在很多网站都进行了前后端分离,后端提供 rest api,前端调用接口获取数据渲染。这种架构下如何保护好后端所提供的 rest api 使得更加重视。
    认证-请求携带的认证信息是否校验通过,鉴权-认证通过的用户拥有指定 api 的权限才能访问此 api 。然而不仅于此,什么样的认证策略, jwt, basic,digest,oauth 还是多支持, 权限配置是写死代码还是动态配置,云原生越来越火用的框架是 quarkus 不是 spring 生态,http 实现不是 servlet 而是 jax-rs 规范咋办。

    在上篇restful api 权限设计 - 初探我们大致说到了要保护我们 restful api 的认证鉴权所需的方向点。多说无益,现在就一步一步来实战下基于 springboot sureness 来快速搭建一个完整功能的权限认证项目。


    这里为了照顾到刚入门的同学,图文展示了每一步操作。有基础可直接略过。

    初始化一个 springboot web 工程

    在 IDEA 如下操作:

    image

    image

    image

    image

    提供一些模拟的 restful api

    新建一个 controller, 在里面实现一些简单的 restful api 供外部测试调用

    /**
     * simulate api controller, for testing
     * @author tomsun28
     * @date 17:35 2019-05-12
     */
    @RestController
    public class SimulateController {
    
        /** access success message **/
        public static final String SUCCESS_ACCESS_RESOURCE = "access this resource success";
    
        @GetMapping("/api/v1/source1")
        public ResponseEntity<String> api1Mock1() {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @PutMapping("/api/v1/source1")
        public ResponseEntity<String> api1Mock3() {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @DeleteMapping("/api/v1/source1")
        public ResponseEntity<String> api1Mock4() {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @GetMapping("/api/v1/source2")
        public ResponseEntity<String> api1Mock5() {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @GetMapping("/api/v1/source2/{var1}/{var2}")
        public ResponseEntity<String> api1Mock6(@PathVariable String var1, @PathVariable Integer var2 ) {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @PostMapping("/api/v2/source3/{var1}")
        public ResponseEntity<String> api1Mock7(@PathVariable String var1) {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
        @GetMapping("/api/v1/source3")
        public ResponseEntity<String> api1Mock11(HttpServletRequest request) {
            return ResponseEntity.ok(SUCCESS_ACCESS_RESOURCE);
        }
    
    }
    

    项目中加入 sureness 依赖

    在项目的 pom.xml 加入 sureness 的 maven 依赖坐标

    <dependency>
        <groupId>com.usthe.sureness</groupId>
        <artifactId>sureness-core</artifactId>
        <version>0.4.3</version>
    </dependency>
    

    如下:

    image

    使用默认配置来配置 sureness

    新建一个配置类,创建对应的 sureness 默认配置 bean
    sureness 默认配置使用了文件数据源sureness.yml作为账户权限数据源
    默认配置支持了jwt, basic auth, digest auth认证

    @Configuration
    public class SurenessConfiguration {
    
        /**
         * sureness default config bean
         * @return default config bean
         */
        @Bean
        public DefaultSurenessConfig surenessConfig() {
            return new DefaultSurenessConfig();
        }
    
    }
    

    配置默认文本配置数据源

    认证鉴权当然也需要我们自己的配置数据:账户数据,角色权限数据等
    这些配置数据可能来自文本,关系数据库,非关系数据库
    我们这里使用默认的文本形式配置 - sureness.yml, 在 resource 资源目录下创建 sureness.yml 文件
    在 sureness.yml 文件里配置我们的角色权限数据和账户数据,如下:

    ## -- sureness.yml 文本数据源 -- ##
    
    # 加载到匹配字典的资源,也就是需要被保护的,设置了所支持角色访问的资源
    # 没有配置的资源也默认被认证保护,但不鉴权
    # eg: /api/v1/source1===get===[role2] 表示 /api/v2/host===post 这条资源支持 role2 这一种角色访问
    # eg: /api/v1/source2===get===[] 表示 /api/v1/source2===get 这条资源支持所有角色或无角色访问 前提是认证成功
    resourceRole:
      - /api/v1/source1===get===[role2]
      - /api/v1/source1===delete===[role3]
      - /api/v1/source1===put===[role1,role2]
      - /api/v1/source2===get===[]
      - /api/v1/source2/*/*===get===[role2]
      - /api/v2/source3/*===get===[role2]
    
    # 需要被过滤保护的资源,不认证鉴权直接访问
    # /api/v1/source3===get 表示 /api/v1/source3===get 可以被任何人访问 无需登录认证鉴权
    excludedResource:
      - /api/v1/account/auth===post
      - /api/v1/source3===get
      - /**/*.html===get
      - /**/*.js===get
      - /**/*.css===get
      - /**/*.ico===get
    
    # 用户账户信息
    # 下面有 admin root tom 三个账户
    # eg: admin 拥有[role1,role2]角色,明文密码为 admin,加盐密码为 0192023A7BBD73250516F069DF18B500
    # eg: root 拥有[role1],密码为明文 23456
    # eg: tom 拥有[role3],密码为明文 32113
    account:
      - appId: admin
        # 如果填写了加密盐--salt,则 credential 为 MD5(password+salt)的 32 位结果
        # 没有盐认为不加密,credential 为明文
        # 若密码加盐 则 digest 认证不支持  
        credential: 0192023A7BBD73250516F069DF18B500
        salt: 123
        role: [role1,role2]
      - appId: root
        credential: 23456
        role: [role1]
      - appId: tom
        credential: 32113
        role: [role3]
    
    

    添加过滤器拦截所有请求,对所有请求进行认证鉴权

    新建一个 filter, 拦截所有请求,用 sureness 对所有请求进行认证鉴权。认证鉴权失败的请求 sureness 会抛出对应的异常,我们捕获响应的异常进行处理返回 response 即可。

    @Order(1)
    @WebFilter(filterName = "SurenessFilterExample", urlPatterns = "/*", asyncSupported = true)
    public class SurenessFilterExample implements Filter {
    
        @Override
        public void init(FilterConfig filterConfig) {}
    
        @Override
        public void destroy() {}
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
                throws IOException, ServletException {
    
            try {
                SubjectSum subject = SurenessSecurityManager.getInstance().checkIn(servletRequest);
                // 认证鉴权成功则会返回带用户信息的 subject 可以将 subject 信息绑定到当前线程上下文 holder 供后面使用
                if (subject != null) {
                    SurenessContextHolder.bindSubject(subject);
                }
            } catch (ProcessorNotFoundException | UnknownAccountException | UnsupportedSubjectException e4) {
                // 账户创建相关异常
                responseWrite(ResponseEntity
                        .status(HttpStatus.BAD_REQUEST).body(e4.getMessage()), servletResponse);
                return;
            } catch (DisabledAccountException | ExcessiveAttemptsException e2 ) {
                // 账户禁用相关异常
                responseWrite(ResponseEntity
                        .status(HttpStatus.UNAUTHORIZED).body(e2.getMessage()), servletResponse);
                return;
            } catch (IncorrectCredentialsException | ExpiredCredentialsException e3) {
                // 认证失败相关异常
                responseWrite(ResponseEntity
                        .status(HttpStatus.UNAUTHORIZED).body(e3.getMessage()), servletResponse);
                return;
            } catch (NeedDigestInfoException e5) {
                // digest 认证需要重试异常
                responseWrite(ResponseEntity
                        .status(HttpStatus.UNAUTHORIZED)
                        .header("WWW-Authenticate", e5.getAuthenticate()).build(), servletResponse);
                return;
            } catch (UnauthorizedException e6) {
                // 鉴权失败相关异常,即无权访问此 api
                responseWrite(ResponseEntity
                        .status(HttpStatus.FORBIDDEN).body(e6.getMessage()), servletResponse);
                return;
            } catch (RuntimeException e) {
                // 其他异常
                responseWrite(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(),
                        servletResponse);
                return;
            }
            try {
                // 若未抛出异常 则认证鉴权成功 继续下面请求流程
                filterChain.doFilter(servletRequest, servletResponse);
            } finally {
                SurenessContextHolder.clear();
            }
        }
    
        /**
         * write response json data
         * @param content content
         * @param response response
         */
        private static void responseWrite(ResponseEntity content, ServletResponse response) {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json;charset=utf-8");
            ((HttpServletResponse)response).setStatus(content.getStatusCodeValue());
            content.getHeaders().forEach((key, value) ->
                    ((HttpServletResponse) response).addHeader(key, value.get(0)));
            try (PrintWriter printWriter = response.getWriter()) {
                if (content.getBody() != null) {
                    if (content.getBody() instanceof String) {
                        printWriter.write(content.getBody().toString());
                    } else {
                        ObjectMapper objectMapper = new ObjectMapper();
                        printWriter.write(objectMapper.writeValueAsString(content.getBody()));
                    }
                } else {
                    printWriter.flush();
                }
            } catch (IOException e) {}
        }
    }
    
    

    像上面一样,

    1. 若认证鉴权成功,checkIn会返回包含用户信息的SubjectSum对象
    2. 若中间认证鉴权失败,checkIn会抛出不同类型的认证鉴权异常,用户需根据这些异常来继续后面的流程(返回相应的请求响应)

    为了使 filter 在 springboot 生效 需要在 boot 启动类加注解 @ServletComponentScan

    @SpringBootApplication
    @ServletComponentScan
    public class BootstrapApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(BootstrapApplication.class, args);
        }
    }
    

    一切完毕,验证测试

    通过上面的步骤 我们的一个完整功能认证鉴权项目就搭建完成了,有同学想 就这几步骤 它的完整功能体现在哪里啊 能支持啥。

    这个搭好的认证鉴权项目基于 rbac 权限模型,支持 baisc 认证,digest 认证, jwt 认证。能细粒度的控制用户对后台提供的 restful api 的访问权限,即哪些用户能访问哪些 api 。 我们这里来测试一下。

    IDEA 上启动工程项目。

    basic 认证测试

    认证成功

    image

    密码错误

    image

    账户不存在

    image

    digest 认证测试

    注意如果密码配置了加密盐,则无法使用 digest 认证

    image

    image

    jwt 认证测试

    jwt 认证首先你得拥有一个签发的 jwt,创建如下 api 接口提供 jwt 签发- /api/v1/account/auth

    @RestController()
    public class AccountController {
    
        private static final String APP_ID = "appId";
        /**
         * account data provider
         */
        private SurenessAccountProvider accountProvider = new DocumentAccountProvider();
    
        /**
         * login, this provider a get jwt api, convenient to test other api with jwt
         * @param requestBody request
         * @return response
         *
         */
        @PostMapping("/api/v1/account/auth")
        public ResponseEntity<Object> login(@RequestBody Map<String,String> requestBody) {
            if (requestBody == null || !requestBody.containsKey(APP_ID)
                    || !requestBody.containsKey("password")) {
                return ResponseEntity.badRequest().build();
            }
            String appId = requestBody.get("appId");
            String password = requestBody.get("password");
            SurenessAccount account = accountProvider.loadAccount(appId);
            if (account == null || account.isDisabledAccount() || account.isExcessiveAttempts()) {
                return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
            }
            if (account.getPassword() != null) {
                if (account.getSalt() != null) {
                    password = Md5Util.md5(password + account.getSalt());
                }
                if (!account.getPassword().equals(password)) {
                    return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
                }
            }
            // Get the roles the user has - rbac
            List<String> roles = account.getOwnRoles();
            long refreshPeriodTime = 36000L;
            // issue jwt
            String jwt = JsonWebTokenUtil.issueJwt(UUID.randomUUID().toString(), appId,
                    "token-server", refreshPeriodTime >> 1, roles,
                    null, Boolean.FALSE);
            Map<String, String> body = Collections.singletonMap("token", jwt);
            return ResponseEntity.ok().body(body);
        }
    
    
    }
    

    请求 api 接口登录认证获取 jwt

    image

    携带使用获取的 jwt 值请求 api 接口

    image image

    鉴权测试

    通过上面的 sureness.yml 文件配置的用户-角色-资源,我们可以关联下面几个典型测试点

    1. /api/v1/source3===get资源可以被任何直接访问,不需要认证鉴权
    2. api/v1/source2===get资源持所有角色或无角色访问 前提是认证成功
    3. 用户 admin 能访问/api/v1/source1===get资源,而用户 root,tom 无权限
    4. 用户 tom 能访/api/v1/source1===delete资源,而用户 admin.root 无权限
      测试如下:

    /api/v1/source3===get资源可以被任何直接访问,不需要认证鉴权
    image

    api/v1/source2===get资源持所有角色或无角色访问 前提是认证成功
    image

    用户 admin 能访问/api/v1/source1===get资源,而用户 root,tom 无权限
    image image

    用户 tom 能访/api/v1/source1===delete资源,而用户 admin.root 无权限
    image image

    其他

    这次图文一步一步的详细描述了构建一个简单但完整的认证鉴权项目的流程,当然里面的授权账户等信息是写在配置文件里面的,实际的项目是会把这些数据写在数据库中。万变不离其宗,无论是写配置文件还是数据库,它只是作为数据源提供数据,基于 sureness 我们也能轻松快速构建基于数据库的认证鉴权项目,支持动态刷新等各种功能,这个就下次再写咯。

    若等不及可以直接去看基于数据库的认证鉴权 DEMO 仓库地址: https://github.com/tomsun28/sureness/tree/master/sample-tom


    源代码仓库

    这篇文章的完整 DEMO 代码仓库地址: https://github.com/tomsun28/sureness/tree/master/sample-bootstrap

    13 条回复    2021-02-02 23:29:52 +08:00
    shenlanAZ
        1
    shenlanAZ  
       2021-01-25 15:01:08 +08:00
    终于在 v2 上看到有人使用 ResponseEntity 返回了。想问一下 对 reactive 支持怎么样?
    tomsun28
        2
    tomsun28  
    OP
       2021-01-25 15:17:29 +08:00   ❤️ 1
    @shenlanAZ 哈哈 reactive 支持哦 这个不像 spring security 没有框架的依赖 很巧的是我刚好写了个 sureness 集成 spring-webflux 的 demo :wink: https://github.com/tomsun28/sureness/tree/master/samples/spring-webflux-sureness
    ReinerShir
        3
    ReinerShir  
       2021-01-25 15:17:58 +08:00
    楼主这种角色和权限是配置死的吗?没办法动态配置角色的权限吧?
    另外 ResponseEntity 没有业务状态码,如果把业务状态码放自己包装的实体里那还不如直接用自定义实体,使用 @ResponseStatus(201) 也可以达到效果
    tomsun28
        4
    tomsun28  
    OP
       2021-01-25 15:25:52 +08:00
    @ReinerShir 这个 10 分钟的 demo 没法动态配置角色权限 但是是可以更改数据源为数据库来支持的 文章最后有说,具体可以见 https://github.com/tomsun28/sureness/tree/master/sample-tom
    这是演示 api 代码 所以 ResponseEntity 状态码比较随意都是 200 真实项目肯定是你说的那种要好些的
    mitsuizzz
        5
    mitsuizzz  
       2021-01-25 18:21:39 +08:00
    简单易懂好上手 star 了
    chendy
        6
    chendy  
       2021-01-25 18:27:25 +08:00
    @shenlanAZ 除了异常处理场景,一般也用不上 ResposneEntity 吧
    zhaojun1998
        7
    zhaojun1998  
       2021-01-25 19:08:19 +08:00
    @tomsun28

    之前做过一个改造 shiro 的工程,让其支持了 http method 粒度的鉴权,原理和你这个类似,就是 /url === httpMethod 这种方式。但当时考虑到手动维护 URL 这个功能很麻烦,所以后来又加入了自动获取 springboot 中所有已映射 URL 的功能,可以更方便的配置 URL,如图:

    https://cdn.jun6.net/2021/01/25/91a2548679fa9.png


    项目预览地址为: https://shiro.jun6.net/
    可以参考下这个功能,做成 springboot 工具类的方式嵌入到项目中,再额外做一些改造,如把 PathVariable 换成权限 URL 匹配的 *
    tomsun28
        8
    tomsun28  
    OP
       2021-01-25 19:22:44 +08:00
    @mitsuizzz thanks 😁😁😁😁

    @shenlanAZ 组装状态码,响应头和体感觉很方便 一直就在用了 场景的话有自定义响应的地方感觉都比较好用
    tomsun28
        9
    tomsun28  
    OP
       2021-01-25 19:37:02 +08:00
    @zhaojun1998 有缘人,3 年前我也有过改造 shiro 让他支持 rest 方式 也是 url === httpMethod 可以看这个项目 https://gitee.com/tomsun28/bootshiro 记得当时还给 shiro 提交 pr 解决了一两个 bug 哈哈
    感谢建议 做成工具的形式来读 spring 的注解得到 url-method 信息很不错,这样方便了用户不用开始的时候去一个个配置,也不会配错
    你的网站不错哦
    comcom
        10
    comcom  
       2021-01-26 00:01:10 +08:00
    不错不错,试了弄下来差不多 10 分钟
    tomsun28
        11
    tomsun28  
    OP
       2021-01-26 08:51:48 +08:00
    @comcom 😁😁😁😁
    120qwer
        12
    120qwer  
       2021-02-01 04:32:13 +08:00 via iPhone
    谢谢分享
    tomsun28
        13
    tomsun28  
    OP
       2021-02-02 23:29:52 +08:00
    @120qwer 😁😁😁😁
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2204 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 01:34 · PVG 09:34 · LAX 17:34 · JFK 20:34
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.