从源码看Spring Security之采坑笔记(Spring Boot篇)

本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

原文链接:blog.ouyangsihai.cn >> 从源码看Spring Security之采坑笔记(Spring Boot篇)

本文为原创投稿文,作者:像风一样 转载请注明出处

一、唠嗑

鼓捣了两天的Spring Security,踩了不少坑。如果你在学Spring Security,恰好又是使用的Spring Boot,那么给我点个赞吧!这篇博客将会让你了解Spring Security的各种坑!

二、开始

1、准备

  • Spring boot 1.5
  • Mysql 5.7
  • 导入依赖
  • 
     !-- Web工程 --
            dependency
                groupIdorg.springframework.boot/groupId
                artifactIdspring-boot-starter-web/artifactId
            /dependency
            !-- 数据库相关 --
            dependency
                groupIdorg.springframework.boot/groupId
                artifactIdspring-boot-starter-data-jpa/artifactId
            /dependency
            dependency
                groupIdmysql/groupId
                artifactIdmysql-connector-java/artifactId
            /dependency
            !-- security 核心 --
            dependency
                groupIdorg.springframework.boot/groupId
                artifactIdspring-boot-starter-security/artifactId
            /dependency
            !-- thymeleaf 模板--
            dependency
                groupIdorg.springframework.boot/groupId
                artifactIdspring-boot-starter-thymeleaf/artifactId
            /dependency
            !-- 可以在HTML使用sec标签操作Security --
            dependency
                groupIdorg.thymeleaf.extras/groupId
                artifactIdthymeleaf-extras-springsecurity4/artifactId
            /dependency
    

    2、开启Security并配置

    
    package cn.zyzpp.security.config;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    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.UserDetailsService;
    
    /**
     * Create by yster@foxmail.com 2018/6/10/010 18:07
     */
    @EnableWebSecurity
    public class MySerurityConfig extends WebSecurityConfigurerAdapter {
        /*自己实现下面两个接口*/
        @Autowired
        private AuthenticationProvider authenticationProvider;
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .antMatchers("/", "/signIn").permitAll()//所有人都可以访问
                    .antMatchers("/leve/1").hasRole("VIP1") //设置访问角色
                    .antMatchers("/leve/2").hasRole("VIP2")
                    .antMatchers("/leve/3").hasAuthority("VIP2")//设置访问权限
                    .anyRequest().authenticated() //其他所有资源都需要认证,登陆后访问
                    .and()
                    .formLogin()//开启自动配置的授权功能
                    .loginPage("/login")    //自定义登录页(controller层需要声明)
                    .usernameParameter("username")  //自定义用户名name值
                    .passwordParameter("password")  //自定义密码name值
                    .failureUrl("/login?error") //登录失败则重定向到此URl
                    .permitAll() //登录页都可以访问
                    .and()
                    .logout()//开启自动配置的注销功能
                    .logoutSuccessUrl("/")//注销成功后返回到页面并清空Session
                    .and()
                    .rememberMe()
                    .rememberMeParameter("remember")//自定义rememberMe的name值,默认remember-Me
                    .tokenValiditySeconds(604800);//记住我的时间/秒
        }
    
        /*定义认证规则*/
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /*  保存用户信息到内存中
            auth.inMemoryAuthentication()
                 .withUser("张三").password("123456").roles("VIP1")
                 .and()
                 .withUser("李四").password("123456").roles("VIP2");
        */
    
            /*自定义认证*/
            auth.authenticationProvider(authenticationProvider);
            auth.userDetailsService(userDetailsService);//不定义的话rememberMe报错
        }
    
        /*忽略静态资源*/
        @Override
        public void configure(WebSecurity web) {
            web.ignoring().antMatchers("/resources/static/**");
        }
    
    }
    
    讲一下:

    我们基本不会把用户信息保存在内存中,所以我们自定义认证方法。这里我推荐阅读 认证(Authentication)与源码解读。

    自定义认证也有两种方法,第一是注入DaoAuthenticationProvider(org.springframework.security.authentication.dao)

    
    @Bean
        public DaoAuthenticationProvider daoAuthenticationProvider(){
            DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
            daoAuthenticationProvider.setUserDetailsService(userDetailsService);//获取用户信息
            daoAuthenticationProvider.setPasswordEncoder(new Md5PasswordEncoder());//MD5加密
            daoAuthenticationProvider.setSaltSource(new SaltSource() {  //加盐
                @Override
                public Object getSalt(UserDetails user) {
                    return user.getUsername();
                }
            });
            return daoAuthenticationProvider;
        }
    

    然后改一下设置

    
    auth.authenticationProvider(authenticationProvider);
    

    这种方法我并不推荐,因为我们把密码错误的异常交给了Security底层去抛出,然而抛出的消息只是Bad credentials 这样的消息提示你会需要?

    所以我们使用第二种方法,如下:

    3、自定义AuthenticationProvider接口实现类

    
    package cn.zyzpp.security.config;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.authentication.DisabledException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.authentication.encoding.Md5PasswordEncoder;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.stereotype.Component;
    
    /**
     * Create by yster@foxmail.com 2018/6/21/021 15:53
     * Authentication 是一个接口,用来表示用户认证信息的
     */
    @Component
    public class MyAuthenticationProvider implements AuthenticationProvider{
        @Autowired
        private MyUserDetailsService userDetailsService;
    
        @Override
        public Authentication authenticate(Authentication authentication){
            //1.获取用户输入的用户名 密码
            String username = authentication.getName();
            String password = (String) authentication.getCredentials();
            //2.关于MD5加密:
            //因为我们是自定义Authentication,所以必须手动加密加盐而不需要再配置。
            password = new Md5PasswordEncoder().encodePassword(password,username);
            //3.由输入的用户名查找该用户信息,内部抛出异常
            UserDetails user = userDetailsService.loadUserByUsername(username);
            //4.密码校验
            if (!password.equals(user.getPassword())) {
                throw new DisabledException("----UserName :" + username + " password error!");
            }
            return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
        }
    
        @Override
        public boolean supports(Class? aClass) {
            return (UsernamePasswordAuthenticationToken.class
                    .isAssignableFrom(aClass));
        }
    
    }
    
    讲一下:

    这里说Security的一个坑:

    相信你也看到了有的教程上说抛出UsernameNotFoundException 用户找不到,BadCredentialsException 坏的凭据,但这两个类都是继承自AuthenticationException抽象类,当你抛出这俩异常时,Security底层会捕捉到你抛出的异常,如图:

    从源码看Spring Security之采坑笔记(Spring Boot篇)

    看到了吧,AuthenticationException异常并不会被抛出,debug调式一下,你就会感受到它的曲折历程,相当感人!然后莫名其妙的被换掉了,而且无解。

    没错,你没看错,AccountStatusException异常被直接抛出了,这正是我们需要的;有的同学可能想到了自定义异常,但我们是结合Security框架,要按人家的规则来,不信你试试。

    附一些常用异常:

    
    /* 
        AuthenticationException常用的的子类:(会被底层换掉,不推荐使用)
        UsernameNotFoundException 用户找不到
        BadCredentialsException 坏的凭据
    
        AccountStatusException用户状态异常它包含如下子类:(推荐使用)
        AccountExpiredException 账户过期
        LockedException 账户锁定
        DisabledException 账户不可用
        CredentialsExpiredException 证书过期
    */
    

    4、自定义UserDetailsService接口实现类

    
    package cn.zyzpp.security.config;
    
    import cn.zyzpp.security.entity.Role;
    import cn.zyzpp.security.service.UserService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.DisabledException;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.stereotype.Component;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * 进行认证的时候需要一个 UserDetailsService 来获取用户的信息 UserDetails,
     * 其中包括用户名、密码和所拥有的权限等。
     * Create by yster@foxmail.com 2018/6/21/021 15:56
     */
    @Component
    public class MyUserDetailsService implements UserDetailsService {
    
        @Autowired
        private UserService userService;
    
        /*
         * 采坑笔记:
         * new SimpleGrantedAuthority("...")时
         * 加前戳是Role,通过hasRole()获取,用来认证角色;
         * 不加前戳是Authoritiy,通过hasAuthority()获取,用来鉴定权限;
         * 总结:加前戳是角色,不加前戳是权限。此前戳只用于本类。
         */
        String role_ = "ROLE_";
    
        @Override
        public UserDetails loadUserByUsername(String username) {
            //1.业务层根据username获取该用户
            cn.zyzpp.security.entity.User user = userService.findUserByUserName(username);
            if (user == null) {
                //这里我们不抛出UsernameNotFoundException因为Security会把我们抛出的该异常捕捉并换掉;
                //这里要明确Security抛出的异常无法被ControllerAdvice捕捉到,无法进行统一异常处理;
                //而我们只需要打印正确的异常消息即可,Security自动把异常添加到HttpServletRequest或HttpSession中
                throw new DisabledException("----UserName :" + username + " not found!");
            }
            //2.从业务层获取用户权限并转为Authorities
            ListGrantedAuthority authorities = new ArrayList();
            for (Role role : user.getRoleList()) {
                authorities.add(new SimpleGrantedAuthority(role.getName()));//设置权限
                authorities.add(new SimpleGrantedAuthority(role_ + role.getName()));//设置角色
            }
            //3.返回Spring定义的User对象
            return new User(username, user.getPassword(), authorities);
        }
    
    }
    
    讲一下:

    我们在保存用户信息到内存中时是这样的

    
    auth.inMemoryAuthentication()
          .withUser("张三")
          .password("123456")
          .roles("ROLE_VIP1")
          .authorities("VIP1")
    

    角色和权限是分开设置的,但我们在自定义时只有权限设置

    
    authorities.add(new SimpleGrantedAuthority("权限名"));
    

    定义以后你会发现这真真真…的是权限,不是角色,联想到上面Security的角色和权限其实是不同的,我想我应该是错过了什么?

    然后翻看Security源码:

    从源码看Spring Security之采坑笔记(Spring Boot篇)

    翻译过来:如果调用hasRole(“ADMIN”)或hasRole(“ROLE_ADMIN”)方法时,当Role前缀为”ROLE_”(默认)时将使用ROLE_ADMIN角色。

    而我们在把用户信息保存到内存时,底层是这样的:

    从源码看Spring Security之采坑笔记(Spring Boot篇)

    解读一下就是在调用.roles(“ROLE_VIP1”)方法注册Role时,先通过role.startsWith(“ROLE_”)断言输入的角色名是否是”ROLE_”开头的,如果不是,补充”RELE_”前戳。

    所以,Security解决角色和权限分开的依据就是是否含有”ROLE_”前戳,该默认前戳也是可以自己修改的。

    ok,继续我们的Security学习之路。

    5、获取Security登录异常信息

    
    package cn.zyzpp.security.controller;
    ...
    import cn.zyzpp.security.service.UserService;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpSession;
    
    /**
     * Create by yster@foxmail.com 2018/6/10/010 18:35
     */
    @Controller
    public class MyController {
        @Autowired
        UserService userService;
    
        @Autowired
        HttpSession session;
        @Autowired
        HttpServletRequest request;
    
        /*ModelMap的Key*/
        final String ERROR = "error";
    
        /**
         * 自定义登录页并进行异常信息提示
         * 需要在Security中设置
         */
        @RequestMapping(value = "/login")
        public String login(ModelMap modelMap){
          /*
           security的AuthenticationException异常自动保存在request或session中
           官方默认保存在Session,但我们自定义过多。我测试是在request中。
           所以在html页面还需要搭配th:if="${param.error!=null}"检查Url是否有参数error
            */
            String key = WebAttributes.AUTHENTICATION_EXCEPTION;
    
            if (session.getAttribute(key)!=null){
    //            System.out.println("request");
                AuthenticationException exception = (AuthenticationException) session.getAttribute(key);
                modelMap.addAttribute(ERROR,exception.getMessage());
            }
            if (request.getAttribute(key)!=null){
    //            System.out.println("session");
                AuthenticationException exception = (AuthenticationException) request.getAttribute(key);
                modelMap.addAttribute(ERROR,exception.getMessage());
            }
    
            return "login";
        }
    
    }
    
    自定义login登录页面

    Security规定若是GET访问则是请求页面,POST访问则为提交登录

    
    !DOCTYPE html
    html xmlns:th="http://www.thymeleaf.org"
    head
        meta charset="UTF-8"/
        title登录页面/title
    /head
    body
    form th:action="@{/login}" method="post"
        用户名:input type="text" placeholder="username" name="username" required=""/br/
        密码:input type="password" placeholder="password" name="password" required=""/br/
        记住我:input type="checkbox" name="remember"/
        input type="submit" value="提交"/
        span th:if="${param.error!=null}" th:text="${error}"/
    /form
    /body
    /html
    
    讲一下:

    如果你debug追踪一下,你就可以了解Security的运行原理

    Security的SimpleUrlAuthenticationFailureHandler(简单认证故障处理)会把异常保存到request或session中,forwardToDestination默认为false,也就是保存在session,实际我们测试是保存在request。

    从源码看Spring Security之采坑笔记(Spring Boot篇)

    6、在view层使用Security

    6.1 使用HTML sec标签 (推荐)
    
    !DOCTYPE html
    html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4"
    head
        meta charset="UTF-8"/
        title首页/title
    /head
    body
    div sec:authorize="isAuthenticated()"
        form th:action="@{/logout}" method="POST"
            input type="submit" value="注销" /
        /form
        user:b sec:authentication="name"/bbr/
        !-- principal对应org.springframework.security.core.userdetails.User类 --
        Role:b sec:authentication="principal.authorities"/b
    /div
    div sec:authorize="!isAuthenticated()"
        h2游客你好!/h2请a th:href="@{/login}"登录/a
    /div
    div sec:authorize="hasRole('VIP1')"
        h2ROLE_VIP1_可见/h2
    /div
    div sec:authorize="hasRole('VIP2')"
        h2ROLE_VIP2_可见/h2
    /div
    div sec:authorize="hasAuthority('VIP1')"
        h2Authority:VIP1_可见/h2
    /div
    /body
    /html
    
    6.2 编码获取用户登录信息

    下面为我自己写的方法,看看就好!

    
    /**
         * 不使用sec标签(不推荐)
         * 在Controller获取用户信息
         */
        @RequestMapping("/index")
        public String index1(ModelMap model){
            userAndRoles(model);
            return "index";
        }
    
        /**
         * Security辅助方法:获取用户信息
         */
        private void userAndRoles(ModelMap model) {
            //从Security获取当前用户会话
            Object principal = SecurityContextHolder.getContext()
                    .getAuthentication()
                    .getPrincipal();
            User user = null;
            //判断用户已经登录
            if (principal instanceof User){
                user = (User) principal;
                //遍历迭代器获取用户权限
                IteratorGrantedAuthority iterator = user.getAuthorities().iterator();
                ListString roles = new ArrayList();
                while (iterator.hasNext()){
                    roles.add(iterator.next().getAuthority());
                }
                //保存角色信息
                model.addAttribute("roles",roles.toString());
            }
            //保存用户信息,未登录为空
            model.addAttribute("user",user);
        }
    

    7、权限及用户的Entity类

    权限表

    
    /**
     * 权限表
     * Create by yster@foxmail.com 2018/6/21/021 18:00
     */
    @Entity
    @Table(name = "role")
    public class Role {
        @Id
        @GeneratedValue
        private int id;
        private String name;
        ...
    }
    

    用户表

    
    /**
     * Create by yster@foxmail.com 2018/6/21/021 17:59
     */
    @Entity
    @Table(name = "user",uniqueConstraints = {@UniqueConstraint(columnNames="username")})
    public class User {
        @Id
        @GeneratedValue
        private int id;
        private String username;
        private String password;
        @OneToMany(cascade={CascadeType.ALL}, fetch=FetchType.EAGER)
        @JoinColumn(name = "r_id")
        private ListRole roleList;
        ....
    }
    

    关注后端技术精选,每天推送优质好文

    从源码看Spring Security之采坑笔记(Spring Boot篇)
    本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

    本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

    原文链接:blog.ouyangsihai.cn >> 从源码看Spring Security之采坑笔记(Spring Boot篇)


     上一篇
    Spring中的线程池 Spring中的线程池
    来源:duanxzcnblogs.com/duanxz/p/5050227.html 前言: Java SE 5.0引入了ThreadPoolExecutor、ScheduledThreadPoolExec
    2021-04-05
    下一篇 
    外行人都能看懂的SpringCloud讲解 外行人都能看懂的SpringCloud讲解
    作者:Java3y 链接:https://juejin.im/post/5b83466b6fb9a019b421cecc 一、前言 这篇主要来讲讲SpringCloud的一些基础的知识,当然了,我的水平是有限的,可能会有一些理解错的的概念&
    2021-04-05