Spring Boot之整合Spring Security: 授

作者: 狄仁杰666 | 来源:发表于2020-09-08 00:49 被阅读0次

    前言

    笔者学习Spring Boot有一段时间了,附上Spring Boot系列学习文章,大家有兴趣可以参考参考:

    1. 5分钟入手Spring Boot;
    2. Spring Boot数据库交互之Spring Data JPA;
    3. Spring Boot数据库交互之Mybatis;
    4. Spring Boot视图技术;
    5. Spring Boot之整合Swagger;
    6. Spring Boot之junit单元测试踩坑;
    7. 如何在Spring Boot中使用TestNG;
    8. Spring Boot之整合logback日志;
    9. Spring Boot之整合Spring Batch:批处理与任务调度;
    10. Spring Boot之整合Spring Security: 访问认证;

    在上一篇文章Spring Boot之整合Spring Security:访问认证中,我们一起学习了Spring Security的访问认证实现,旨在探索如何用Spring Security进行访问认证控制,简单的说就是:

    • 未登录状态下,站点的所有访问均跳转到登录页面,包括API;

    而这样的操作或设置远不能代表真实场景,一般我们会面临以下问题:

    1. 未登录状态下,访问API应返回HTTP 状码401,并伴随提示性response body;
    2. 不同用户需要不同的访问权限,即权限管理;

    今天我们就来探索如何实现这2个需求!

    项目代码仍用已上传的Git Hub仓库,欢迎取阅:

    整体步骤

    1. 准备不同角色的用户;
    2. 准备测试接口;
    3. 美化登录页面;
    4. 授权管理配置;
    5. 验证授权效果;

    1. 准备不同角色的用户;

    1). 规范化角色;

    在上一篇文章Spring Boot之整合Spring Security:访问认证,我们在多处使用角色信息:

    .roles("admin")
    ...
    .roles("user")
    ...
    SimpleGrantedAuthority authority = new SimpleGrantedAuthority("admin");
    ...
    

    像这种多处使用的数据,应该做个集中管理与限制,因此,我们在项目中创建constant包,创建一个枚举类:UserTypeEnum,代码如:

    package com.github.dylanz666.constant;
    
    /**
     * @author : dylanz
     * @since : 09/07/2020
     */
    public enum UserTypeEnum {
        ADMIN,
        USER
    }
    

    然后把所有角色进行重构替换,这样我们将角色进行集中管理与限制,更为严谨;

    • User实体类增加userType;
    package com.github.dylanz666.domain;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.stereotype.Component;
    
    import java.io.Serializable;
    
    /**
     * @author : dylanz
     * @since : 08/31/2020
     */
    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    @Component
    public class User implements Serializable {
        private static final long serialVersionUID = 1L;
    
        private String username;
        private String password;
        private String userType;
    }
    
    • 创建用户时存储角色信息,查询时也查询出角色信息;
    package com.github.dylanz666.service;
    
    import com.github.dylanz666.constant.UserTypeEnum;
    import com.github.dylanz666.domain.User;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.util.CollectionUtils;
    
    import java.util.ArrayList;
    import java.util.LinkedList;
    import java.util.List;
    import java.util.stream.Collectors;
    
    /**
     * @author : dylanz
     * @since : 08/31/2020
     */
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
        private Logger logger = LoggerFactory.getLogger(this.getClass());
    
        @Autowired
        private UserDetailsImpl userService;
        @Autowired
        private UserDetails userDetails;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            //Spring Security要求必须加密密码
            BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    
            //模拟从数据库中取出用户信息,使用的sql如: SELECT * FROM USER WHERE USER_NAME='cherrys'
            List<User> userList = new ArrayList<>();
            User firstUser = new User();
            firstUser.setUsername("cherrys");
            firstUser.setPassword(passwordEncoder.encode("123"));
            firstUser.setUserType(UserTypeEnum.USER.toString());
            userList.add(firstUser);
            User secondUser = new User();
            secondUser.setUsername("randyh");
            secondUser.setPassword(passwordEncoder.encode("456"));
            secondUser.setUserType(UserTypeEnum.USER.toString());
            userList.add(secondUser);
    
            List<User> mappedUsers = userList.stream().filter(s -> s.getUsername().equals(username)).collect(Collectors.toList());
    
            //判断用户是否存在
            User user;
            if (CollectionUtils.isEmpty(mappedUsers)) {
                logger.info(String.format("The user %s is not found !", username));
                throw new UsernameNotFoundException(String.format("The user %s is not found !", username));
            }
            user = mappedUsers.get(0);
            return new UserDetailsImpl(user);
        }
    }
    
    • 使用角色信息时,均在限定范围内:
    .roles(UserTypeEnum.ADMIN.toString())
    ...
    .roles(UserTypeEnum.UAER.toString())
    ...
    SimpleGrantedAuthority authority = new SimpleGrantedAuthority(this.currentUser.getUserType());
    ...
    

    2. 准备测试接口;

    我们准备4种接口,用于Demo授权管理:

    • 任何角色登录均可访问;
    • 无需登录即可访问;
    • ADMIN角色登录方可访问;
    • USER及比USER权限大的角色登录方可访问;
    package com.github.dylanz666.controller;
    
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    /**
     * @author : dylanz
     * @since : 08/30/2020
     */
    @RestController
    public class HelloController {
        @GetMapping("/hello")//任何角色登录均可访问;
        public String sayHello() {
            return "Hello!";
        }
    
        @GetMapping("/ping")//无需登录即可访问;
        public String ping() {
            return "Success!";
        }
    
        @GetMapping("/admin/hello")//ADMIN角色登录方可访问;
        public String adminHello() {
            return "Hello admin!";
        }
    
        @GetMapping("/user/hello")//USER及比USER权限大的角色登录方可访问;
        public String userHello() {
            return "Hello user!";
        }
    }
    

    3. 美化登录页面;

    在上一期文章中,我们使用了自定义的登录页面,但样子实在丑,有同学也许想看下,我们如何自己做个美丽的登录页面,因此我也稍微美化了一下:

    1). 在resources文件夹下创建static文件夹,用于放置静态资源,如图片、CSS文件、js文件等,我们用于放一张登录背景图:

    登录背景图

    2). 更新resources/templates/login.html模板文件:

    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Spring Security Example</title>
    </head>
    <body class="body">
    <div class="main">
        <div class="welcome">Welcome</div>
        <hr/>
        <form th:action="@{/login}" method="post">
            <div>
                <input type="text" name="username" placeholder="username" class="input">
            </div>
            <div>
                <input type="password" name="password" placeholder="password" class="input">
            </div>
            <div th:style="'vertical-align:middle'">
                <button class="button">Sign In</button>
            </div>
        </form>
    </div>
    </body>
    <style>
        .body {
            background-image: url("/20200907.jpg");
            background-repeat: no-repeat;
            background-position: fixed;
            background-size: cover
        }
    
        .welcome {
            font-size:36px;color: white;
        }
    
        .main {
            border:5px solid white;
            border-radius: 5px;
            width: 320px;
            height: 220px;
            margin: 120px auto;
            display: table;
            text-align: center;
            line-height: 40px;
            vertical-align: middle;
            display: table;
        }
    
        .button {
            margin-top: 10px;
            border: none;
            background-color: #4CAF50;
            color: white;
            padding: 12px 30px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 14px;
            border-radius: 3px;
        }
    
        .input {
            margin-top: 10px;
            border: 2px solid #a1a1a1;
            background: white;
            width: 200px;
            height: 18px;
            padding: 12px 30px;
            border-radius: 5px;
            padding: 10px 28px;
            text-decoration: none;
            display: inline-block;
            font-size: 14px;
            border-radius: 3px;
        }
    </style>
    </html>
    

    3). 登录页面效果:


    登录页面效果

    感觉漂亮多了吧!

    4. 授权管理配置;

    1). 自定义无权限报错实体类;

    在授权之前,我们先自定义一个无权限报错实体类,定义当无权限访问时,告知客户端的信息。在domain包下创建AuthorizationException实体类,代码如下:

    package com.github.dylanz666.domain;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.stereotype.Component;
    
    import java.io.Serializable;
    
    /**
     * @author : dylanz
     * @since : 09/07/2020
     */
    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    @Component
    public class AuthorizationException implements Serializable {
        private static final long serialVersionUID = 1L;
    
        private int code;
        private String status;
        private String uri;
        private String message;
    
        @Override
        public String toString() {
            return "{" +
                    "\"code\":\"" + code + "\"," +
                    "\"status\":\"" + status + "\"," +
                    "\"message\":\"" + message + "\"," +
                    "\"uri\":\"" + uri + "\"" +
                    "}";
        }
    }
    
    2). 开放静态资源访问;

    修改config包下的WebMvcConfig类,如:

    package com.github.dylanz666.config;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
    import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    /**
     * @author : dylanz
     * @since : 08/30/2020
     */
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("/home.html").setViewName("home");
            registry.addViewController("/").setViewName("home");
            registry.addViewController("/hello.html").setViewName("hello");
            registry.addViewController("/login.html").setViewName("login");
        }
    
        public void addResourceHandlers(ResourceHandlerRegistry registry) {
            registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
        }
    }
    
    3). 授权管理配置;
    • 修改WebSecurityConfig如下:
    package com.github.dylanz666.config;
    
    import com.github.dylanz666.constant.UserTypeEnum;
    import com.github.dylanz666.domain.AuthorizationException;
    import com.github.dylanz666.service.UserDetailsServiceImpl;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
    import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.builders.WebSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.provisioning.InMemoryUserDetailsManager;
    
    import javax.servlet.http.HttpServletResponse;
    import java.io.PrintWriter;
    
    /**
     * @author : dylanz
     * @since : 08/30/2020
     */
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        @Autowired
        private UserDetailsServiceImpl userDetailsService;
        @Autowired
        private AuthorizationException authorizationException;
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring().antMatchers("/*.jpg", "/*.png", "/*.css", "/*.js");
        }
    
        @Override
        protected void configure(HttpSecurity httpSecurity) throws Exception {
            httpSecurity
                    .authorizeRequests()
                    .antMatchers("/", "/home.html", "/ping").permitAll()//这3个url不用访问认证
                    .antMatchers("/admin/**").hasRole(UserTypeEnum.ADMIN.toString())
                    .antMatchers("/user/**").hasRole(UserTypeEnum.USER.toString())
                    .anyRequest()
                    .authenticated()//其他url都需要访问认证
                    .and()
                    .formLogin()
                    .loginPage("/login.html")//登录页面的url
                    .loginProcessingUrl("/login")//登录表使用的API
                    .permitAll()//login.html和login不需要访问认证
                    .and()
                    .logout()
                    .permitAll()//logout不需要访问认证
                    .and()
                    .csrf()
                    .disable()
                    .exceptionHandling()
                    .accessDeniedHandler(((httpServletRequest, httpServletResponse, e) -> {
                        httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
                        httpServletResponse.setContentType("application/json");
                        authorizationException.setCode(HttpServletResponse.SC_FORBIDDEN);
                        authorizationException.setStatus("FAIL");
                        authorizationException.setMessage("FORBIDDEN");
                        authorizationException.setUri(httpServletRequest.getRequestURI());
                        PrintWriter printWriter = httpServletResponse.getWriter();
                        printWriter.write(authorizationException.toString());
                        printWriter.flush();
                        printWriter.close();
                    }))
                    .authenticationEntryPoint((httpServletRequest, httpServletResponse, e) -> {
                        if (httpServletRequest.getRequestURI().equals("/hello.html")) {
                            httpServletResponse.sendRedirect("/login.html");
                            return;
                        }
                        httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        httpServletResponse.setContentType("application/json");
                        authorizationException.setCode(HttpServletResponse.SC_UNAUTHORIZED);
                        authorizationException.setStatus("FAIL");
                        authorizationException.setUri(httpServletRequest.getRequestURI());
                        authorizationException.setMessage("UNAUTHORIZED");
                        PrintWriter printWriter = httpServletResponse.getWriter();
                        printWriter.write(authorizationException.toString());
                        printWriter.flush();
                        printWriter.close();
                    });
            httpSecurity.userDetailsService(userDetailsService());
            httpSecurity.userDetailsService(userDetailsService);
        }
    
        @Bean
        @Override
        public UserDetailsService userDetailsService() {
            BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
            UserDetails dylanz =
                    User.withUsername("dylanz")
                            .password(bCryptPasswordEncoder.encode("666"))
                            .roles(UserTypeEnum.ADMIN.toString())
                            .build();
            UserDetails ritay =
                    User.withUsername("ritay")
                            .password(bCryptPasswordEncoder.encode("888"))
                            .roles(UserTypeEnum.USER.toString())
                            .build();
            UserDetails jonathanw =
                    User.withUsername("jonathanw")
                            .password(bCryptPasswordEncoder.encode("999"))
                            .roles(UserTypeEnum.USER.toString())
                            .build();
            return new InMemoryUserDetailsManager(dylanz, ritay, jonathanw);
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public RoleHierarchy roleHierarchy() {
            RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
            roleHierarchy.setHierarchy("ROLE_" + UserTypeEnum.ADMIN.toString() + " > ROLE_" + UserTypeEnum.USER.toString());
            return roleHierarchy;
        }
    }
    

    我们来解读一下:

    • "/.jpg", "/.png", "/.css", "/.js" 这几种类型的资源访问,均不需要认证;
    • 对API进行授权,不同API需要不同的角色:
    //以admin开头的API,需要ADMIN或更大权限的角色;
    .antMatchers("/admin/**").hasRole(UserTypeEnum.ADMIN.toString())
    //以user开头的API,需要USER或更大权限的角色;
    .antMatchers("/user/**").hasRole(UserTypeEnum.USER.toString())
    
    • 当权限不足时,我们自定义了权限不足逻辑:
      (1). 访问的资源时,由于权限不足,角色权限不足,则API报403,且API返回我们自定义的无权限报错信息;
      (2). 当用户访问资源时,由于用户未登录,则API报401,且API返回我们自定义的无权限报错信息;
      (3). 当用户访问资源时,权限不足且访问的是/hello.html页面,则重定向到登录页面/login.html,这样避免权限不足访问/hello.html页面时也报401;
                    .exceptionHandling()
                    .accessDeniedHandler(((httpServletRequest, httpServletResponse, e) -> {                    httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
                        httpServletResponse.setContentType("application/json");
                        authorizationException.setCode(HttpServletResponse.SC_FORBIDDEN);
                        authorizationException.setStatus("FAIL");
                        authorizationException.setMessage("FORBIDDEN");
                        authorizationException.setUri(httpServletRequest.getRequestURI());
                        PrintWriter printWriter = httpServletResponse.getWriter();
                        printWriter.write(authorizationException.toString());
                        printWriter.flush();
                        printWriter.close();
                    }))
                    .authenticationEntryPoint((httpServletRequest, httpServletResponse, e) -> {
                        if (httpServletRequest.getRequestURI().equals("/hello.html")) {
                            httpServletResponse.sendRedirect("/login.html");
                            return;
                        }
                        httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        httpServletResponse.setContentType("application/json");
                        authorizationException.setCode(HttpServletResponse.SC_UNAUTHORIZED);
                        authorizationException.setStatus("FAIL");
                        authorizationException.setUri(httpServletRequest.getRequestURI());
                        authorizationException.setMessage("UNAUTHORIZED");
                        PrintWriter printWriter = httpServletResponse.getWriter();
                        printWriter.write(authorizationException.toString());
                        printWriter.flush();
                        printWriter.close();
                    });
    
    4). 角色继承;

    在实际使用场景中,有些角色拥有其他角色的所有权限,这时,如果为每个角色都单独创建完整的权限表,那么有时候会相当冗余。
    因此,这时候我们就要用到Spirng Security中的角色继承;
    在WebSecurityConfig类中,我写了一个角色继承的例子:

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_" + UserTypeEnum.ADMIN.toString() + " > ROLE_" + UserTypeEnum.USER.toString());
        return roleHierarchy;
    }
    

    即:USER继承于ADMIN角色,USER角色只拥有ADMIN的部分功能,而ADMIN拥有USER角色的所有功能;
    注意角色继承的写法,每个角色前要加ROLE_,继承时用 > 符号连接,符号左边权限大,符号右边权限小。

    5). 资源多角色访问配置;

    假设我们有些/any开头的API,可以给多个角色使用,如SUPERVISOR角色(假设有这个角色)和USER角色,我们可以在WebSecurityConfig中这么配置:

    .antMatchers("/any/**").hasAnyRole(UserTypeEnum.SUPERVISOR.toString(), UserTypeEnum.USER.toString())
    

    5. 验证授权效果;

    启动项目:


    启动项目

    开始验证:

    1). 访问不用访问认证的API;
    访问不用访问认证的API
    2). 访问需要任意角色通过访问认证的API;
    • 登录前:


      需任意角色认证API,登录前
    • 登录后:

    需任意角色认证API,登录后
    3). 访问需要ADMIN角色通过访问认证的API;
    • 登录前:


      需要ADMIN角色认证API,登录前
    • 登录后:


      需要ADMIN角色认证API,登录后
    4). 访问需要USER角色通过访问认证的API;
    • 登录前:


      需要USER角色认证API,登录前
    • 登录后:


      需要USER角色认证API,登录后
    • 访问权限外API:(注意,此时的API status code为403,可以从浏览器的Network中查看)


      访问权限外API
    5). 访问不存在的API;

    我没有额外定制不存在的错误信息或错误页面,默认为:


    访问不存在的API

    Controller中授权管理

    除了上述在WebSecurityConfig统一对API进行授权,我们还可以在项目的Controller中进行授权管理,步骤:

    1. WebSecurityConfig类添加注解:@EnableGlobalMethodSecurity(prePostEnabled = true),如:
    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    
    2. Controller内增加另外的API,并在API中配置权限,而不是在WebSecurityConfig中配置:
        @GetMapping("/controller/hello")
        @PreAuthorize(value="isAuthenticated()")//任何角色登录均可访问;
        public String controllerAnyHello() {
            return "Hello controller any!";
        }
    
        @GetMapping("/controller/admin/hello")
        @PreAuthorize("hasRole('ADMIN')")//ADMIN角色登录方可访问;
        public String controllerAdminHello() {
            return "Hello controller admin!";
        }
    
        @GetMapping("/controller/both/hello")
        @PreAuthorize("hasAnyRole('ADMIN', 'USER')")//ADMIN或USER角色登录方可访问;
        public String controllerBothHello() {
            return "Hello controller both!";
        }
    

    简单分析一下:

    1). 使用@EnableGlobalMethodSecurity注解后,Controller中的@PreAuthorize方可生效;
    2). @PreAuthorize注解内可以指定权限,如:

    • @PreAuthorize(value="isAuthenticated()"),代表//任何角色登录均可访问;
    • @PreAuthorize("hasRole('ADMIN')"),代表ADMIN角色登录方可访问;
    • @PreAuthorize("hasAnyRole('ADMIN', 'USER')"),可用hasAnyRole为指定的多个角色进行授权;
    • 这种注解方式,hasRole和hasAnyRole内的角色不能引用UserTypeEnum内的值,只能手填hard code;

    效果:

    与在WebSecurityConfig配置的效果是一样的,但该方式可对每个API进行单独配置,不会导致WebSecurityConfig配置在复杂应用里头的配置很长,并且对开发人员更加直观,授权管理也更加灵活;

    总结

    至此,我们学会了对API、资源进行授权管理,若结合之前学的访问认证,则我们已能够对应用进行灵活的访问控制和权限控制,可以满足大部分认证授权场景!

    如果本文对您有帮助,麻烦点赞+关注!

    谢谢!

    相关文章

      网友评论

        本文标题:Spring Boot之整合Spring Security: 授

        本文链接:https://www.haomeiwen.com/subject/asslektx.html