乐趣区

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

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

一:唠嗑

  • 鼓捣了两天的 Spring Security,踩了不少坑。如果你在学 Spring Security,恰好又是使用的 Spring Boot,那么给我点个赞吧!这篇博客将会让你了解 Spring Security 的各种坑!
  • 阅读前说一下,这篇博客是我一字一字打出来的,转载务必注明出处哦!
  • 另外,本文已授权微信公众号“后端技术精选”独家发布

二:开始

1. 准备

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

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 底层会捕捉到你抛出的异常,如图:
  • 看到了吧,AuthenticationException异常并不会被抛出,debug 调式一下,你就会感受到它的曲折历程,相当感人!然后莫名其妙的被换掉了,而且无解。
  • 没错,你没看错,AccountStatusException异常被直接抛出了,这正是我们需要的;有的同学可能想到了自定义异常,但我们是结合 Security 框架,要按人家的规则来,不信你试试。
  • 附一些常用异常
<span class="hljs-comment">/* 
   AuthenticationException 常用的的子类:(会被底层换掉,不推荐使用)
   UsernameNotFoundException 用户找不到
   BadCredentialsException 坏的凭据

   AccountStatusException 用户状态异常它包含如下子类:(推荐使用)AccountExpiredException 账户过期
   LockedException 账户锁定
   DisabledException 账户不可用
   CredentialsExpiredException 证书过期
/</span>

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
        List<GrantedAuthority> 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);
    }

}

讲一下:

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

  • 我们在保存用户信息到内存中时是这样的
auth.inMemoryAuthentication()
    .withUser("张三")
    .password("123456")
    .roles("ROLE_VIP1")
    .authorities("VIP1")
  • 角色和权限是分开设置的,但我们在自定义时只有权限设置,
authorities.add(new SimpleGrantedAuthority("权限名"));
  • 定义以后你会发现这真真真…的是权限,不是角色,联想到上面 Security 的角色和权限其实是不同的,我想我应该是错过了什么?
  • 然后翻看 Security 源码:

  • 翻译过来:如果调用 hasRole(“ADMIN”)或 hasRole(“ROLE_ADMIN”)

方法时,当 Role 前缀为”ROLE_”(默认)时将使用 ROLE_ADMIN 角色。

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

  • 解读一下就是在调用 .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(简单认证故障处理)会把异常保存到requestsession中,forwardToDestination默认为false,也就是保存在session,实际我们测试是保存在request


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"></b><br/>
    <!-- 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')">
    <h2>ROLE_VIP1_可见 </h2>
</div>
<div sec:authorize="hasRole('VIP2')">
    <h2>ROLE_VIP2_可见 </h2>
</div>
<div sec:authorize="hasAuthority('VIP1')">
    <h2>Authority: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;
        // 遍历迭代器获取用户权限
        Iterator<GrantedAuthority> iterator = user.getAuthorities().iterator();
        List<String> roles = new ArrayList<>();
        while (iterator.hasNext()){roles.add(iterator.next().getAuthority());
        }
        // 保存角色信息
        model.addAttribute("roles",roles.toString());
    }
    // 保存用户信息,未登录为空
    model.addAttribute("user",user);
}

6. 权限及用户的 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 List<Role> roleList;
    ....
}

  • 关于 Security 的部分先到这里,之所以写这篇博客,源于网上的相关资料略少,坑略多,毕竟做伸手党做惯了,一些坑踩的还是不容易的!

2019/1/ 9 补充

Spring Security 在方法级别上的保护

Spring Security 从 2.0 版本开始,提供了方法级别的安全支持,并提供了 JSR-250 的支持。写一个配置类 SecurityConfig 继承 WebSecurityConfigurationAdapter,并加上相关注解,就可以开启方法级别的保护。

@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SerurityConfig extends WebSecurityConfigurerAdapter {}

在上面的配置代码中,@EnableGlobalMethodSecurity(prePostEnabled = true) 注解开启了方法级别的保护,括号后面的参数可选,可选的参数如下。

  • prePostEnabled:Spring Security 的 Pre 和 Post 注解是否可用,即 @PreAuthorize 和 @PostAuthorize 是否可用。
  • secureEnabled:Spring Security 的 @Service 注解是否可用。
  • jsr250Enabled:Spring Security 对 JSR-250 的注解是否可用。

一般来说,只会用到 prePostEnabled。因为 即 @PreAuthorize 注解比 @PostAuthorize 注解更适合方法级别的安全控制,并且支持 Spring EL 表达式,适合 Spring 开发者。其中,@PreAuthorize 注解会在进入方法钱进行权限验证,@PostAuthorize 注解在方法执行后再进行权限验证。

如何在方法上写权限注解呢?
例如有权限点字符串“ROLE_ADMIN”,在方法上可以写为 @PreAuthorize(“hasRole(‘ADMIN’)”),也可以写为 @PreAuthorize(“hasAuthority(‘ROLE_ADMIN’)”),这二者是等价的。加多个权限点,可以写为 @PreAuthorize(“hasRole(‘ADMIN’,‘USER’)”)、@PreAuthorize(“hasAuthority(‘ROLE_ADMIN’,‘ROLE_USER’)”)。

版权声明

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

退出移动版