彻底弄清SpringSecurity登录原理及开发步骤

28,796 阅读10分钟

SpringBoot+Vue之SpringSecurity登录与授权(一)

工具:idea2018,springboot 2.1.4,springsecurity 5.1.5

简介

SpringSecurity是Spring下的一个安全框架,与shiro 类似,一般用于用户认证(Authentication)和用户授权(Authorization)两个部分,常与与SpringBoot相整合。

开发步骤

便于理解,下一节再使用前后端分离,并引入数据库用户和角色信息

测试登录

1 导入依赖

(pom.xml)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.0.1</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

2 编写测试方法

(controller.UserController)

@Controller
public class UserController {

    @GetMapping("/hello")
    @ResponseBody
    public String hello() {
        return "hello controller";
    }
}    

3 测试

启动项目,浏览器访问:localhost:8080/hello,地址栏自动跳转到http://localhost:8080/login,进入默认登陆页面,验证登录

Username默认为user,Password随机生成(实际就是UUID),查看控制台。

Spring Security默认进行URL访问进行拦截,并提供了验证的登录页面

输入密码,我这里目前是c1068cdb-18f3-48f4-b838-7698218d14c4。登录成功

这里的用户名和密可以修改,直接在配置文件中修改登录名和密码,如

(application.properties)

spring.security.user.name=admin
spring.security.user.password=123

切入源码

1> 用户参数

参照源码,查看静态内部类。可以看出,默认用户的密码实际就是一个UUID。

(SpringSecurity -- SecurityProperties.java)

@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
    ...
    // 默认用户
    private User user = new User();
    ...
	
    public static class User {
       // 默认用户名
        private String name = "user";

        // 默认用户名的默认密码,随机生成
        private String password = UUID.randomUUID().toString();

        // 默认用户名的角色
        private List<String> roles = new ArrayList<>();

        // 是否生成密码
        private boolean passwordGenerated = true;

       ...     
    }    
}

2> 用户名密码验证

  • 导入security依赖后,默认访问的路径将经过该过滤器,并访问其无参构造,创建一个新的post方式的登录请求,路径为/login

  • 进入默认登录页

  • 通过HttpServletRequest对象获取到登录表单中的用户名和密码

  • 创建一个用户名和密码的令牌对象

  • 处理登陆表单的信息

(SpringSecurity -- UsernamePasswordAuthenticationFilter.java)

// @since spring security 3.0
public class UsernamePasswordAuthenticationFilter extends
      AbstractAuthenticationProcessingFilter {

   public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
   public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

   private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
   private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
   private boolean postOnly = true;

    // 构造器,以不区分大小写的方式post方式和HTTP方法创建匹配器。
   public UsernamePasswordAuthenticationFilter() {
      super(new AntPathRequestMatcher("/login", "POST"));
   }

   public Authentication attemptAuthentication(HttpServletRequest request,
         HttpServletResponse response) throws AuthenticationException {
      if (postOnly && !request.getMethod().equals("POST")) {
         throw new AuthenticationServiceException(
               "Authentication method not supported: " + request.getMethod());
      }
		
      // 从请求路径获取用户名和密码 
      String username = obtainUsername(request);
      String password = obtainPassword(request);

       // 空值判断
      if (username == null) {
         username = "";
      }

      if (password == null) {
         password = "";
      }

       // 去除用户名首尾空格
      username = username.trim();

       // 生成一个用户名密码身份验证的令牌
      UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);

      // 设置身份认证请求的信息
      setDetails(request, authRequest);

       // 返回一个完全经过身份验证的对象,包括凭据
      return this.getAuthenticationManager().authenticate(authRequest);
   }
    ....
    
    protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(usernameParameter);
	}
}    

自定义登录接口

(为便于解释,不引入数据库信息验证)

1 实现接口

实现UserDetailsService接口,重写方法。

(service.MyUserDetailsSerice)

/**
 * 自定义登录接口(核心接口,加载用户特定的数据。)
 */
@Component
public class MyUserDetailsSerice implements UserDetailsService {
    // 日志 返回与作为参数传递的类对应的日志程序
    private static final Logger logger = LoggerFactory.getLogger(UserDetailsService.class);


    /**
     * 校验,根据用户名定位用户
     * @param username 标识需要其数据的用户的用户名。
     * @return 核心用户信息,一个完全填充的用户记录
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("登录,用户名:{}", username);
        return new User(username, "123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

2 配置登录拦截

继承WebSecurityConfigurerAdapter配置类,重写里面的配置方法

配置方法可查看官网springboot或查看EnableWebSecurity接口的注释信息

(config.MySecurityConfig)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        super.configure(http);
        // 基础配置
        http.httpBasic()
                .and()
                // 身份认证
                .authorizeRequests()
                // 所有请求
                .anyRequest()
                // 身份认证
                .authenticated();
    }        

返回的User实现了UserDetail接口,详情见切入源码

3 测试

启动项目,清除浏览器缓存,访问hello,跳转到默认登录页面,校验密码。登录时,用户名任意,密码必须为123(MyUserDetailsSerice中已配置)。

登录失败,控制台打印,没有针对id“null”PasswordEncoder(映射的密码编码器)

4 加入密码编码器组件

继承PassawordEncoder接口

/**
 * 用于编码密码的服务接口的实现类。
 */
@Component
public class MyPasswordEncoder implements PasswordEncoder {

    /**
     *  编码原始密码。通常,良好的编码算法应用SHA-1或更大的哈希与8字节或更大的随机生成的盐相结合。
     * @param rawPassword 密码,一个可读的字符值序列
     * @return
     */
    @Override
    public String encode(CharSequence rawPassword) {
        return rawPassword.toString();
    }

    /**
     * 验证从存储中获得的编码密码是否与提交的原始密码匹配。如果密码匹配,返回true;如果不匹配,返回false。存储的密码本身永远不会被解码。
     * @param rawPassword 预设的验证密码。要编码和匹配的原始密码
     * @param encodedPassword 表单输入的密码。来自存储的编码密码与之比较
     * @return
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(rawPassword.toString());
    }
}

4 测试

重启项目,清除浏览器缓存,访问hello。

切入源码

1 关于WebSecurityConfigurerAdapter可参考接口EnableWebSecurity

(SpringSecurity -- EnableWebSecurity)

/**
 * Add this annotation to an {@code @Configuration} class to have the Spring Security
 * .............
 * 	&#064;Override
 * 	protected void configure(HttpSecurity http) throws Exception {
 * 		http.authorizeRequests().antMatchers(&quot;/public/**&quot;).permitAll().anyRequest()
 * 				.hasRole(&quot;USER&quot;).and()
 * 				// 更多配置 ...
 * 				.formLogin() // 确保基础表单登录
 * 				// 为所有与表单登录相关联的URL设置许可证
 * 				.permitAll();
 * 	}
 *
 * ...................
 * @since 3.2
 */
...
@Import({ WebSecurityConfiguration.class,
		SpringWebMvcImportSelector.class,
		OAuth2ImportSelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {

	// 默认关闭debug模式
	boolean debug() default false;
}

2 security中封装的默认用户User的信息 (SpringSecurity -- User.java)

// 
public class User implements UserDetails, CredentialsContainer{
   ...
    private String password;
	private final String username;
    // 用户权限集合
	private final Set<GrantedAuthority> authorities;
    // 账户未过期
	private final boolean accountNonExpired;
    // 账户未锁定
	private final boolean accountNonLocked;
    // 凭据未过期
	private final boolean credentialsNonExpired;
    // 用户可用
	private final boolean enabled;
    ...
}

密码加密

1 注入密码编码器对象

继承WebSecurityConfigurerAdapter配置类

在MySecurity中直接注入一个BCryptPasswordEncoder对象。它实现了PasswordEncoder接口,并重写了encodematches方法

(config.MySecurityConfig.java)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 实现使用BCrypt强哈希函数的密码编码器。客户机可以选择性地提供“强度”(即BCrypt中的日志轮数)和SecureRandom 实例。
     * 强度参数越大,需要做的工作就越多(指数级)来散列密码。默认值是10。
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    ...
}

2 完善服务层

完善MyUserDetailsSerice

(service.MyUserDetailsSerice.java)

@Component
public class MyUserDetailsSerice implements UserDetailsService {
	...
        
    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String password = passwordEncoder.encode("123");
        logger.info("登录,用户名:{},密码:{}", username,password);
        return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

3 测试

注释掉MyPasswordEncoder的@component注解,使其失去容器组件身份

使用debug模式,启动项目,访问hello。

debug可看到密码的转化,原始密码123加密为为$2a10YGYb9i0ZjnTHPlOk/NQb/efrPNOaJq8hJYtdXf8VcdQUi8T8S3Iim

控制台打印日志

切入源码

可以看到,这里自动注入的其实是BCryptPasswordEncoder对象,并调用了encode方法

(SpringSecurity -- BCryptPasswordEncoder)

// 构造器
public BCryptPasswordEncoder() {
	this(-1);
}
public BCryptPasswordEncoder(int strength) {
    ...
}
public BCryptPasswordEncoder(int strength, SecureRandom random) {
    ...
}
...
    
public String encode(CharSequence rawPassword) {
    // 盐值
   String salt;
    // 判断构造器是否有相应参数
   if (strength > 0) {
      if (random != null) {
          // 通过random和strength生成的salt
         salt = BCrypt.gensalt(strength, random);
      }
      else {
           // 通过strength生成的salt
         salt = BCrypt.gensalt(strength);
      }
   }
    // 无参构造
   else {
       // 调用gensalt(GENSALT_DEFAULT_LOG2_ROUNDS);随机生成salt
       // GENSALT_DEFAULT_LOG2_ROUNDS = 10
      salt = BCrypt.gensalt();
   }
    // 使用OpenBSD bcrypt方案散列密码,参数分别为原始密码和盐值
   return BCrypt.hashpw(rawPassword.toString(), salt);
}
  • 这里BCryptPasswordEncoder使用的无参,使用默认的盐值,循环10次,生成了散列的密码。

  • 这里虽然是123,但每次加密后都不相同,Spring Security在进行密码加密的时候,生成了一份随机salt,最终加密的密码=密码+随机salt。

  • 注意这里的AuthorityUtils的方法,参数包含角色信息。实际业务中,一般以“ROLE_**”规定用户的角色字段,并在登录后授予相应权限

/**
 *从逗号分隔的字符串表示创建一个GrantedAuthority对象数组(例如“ROLE_A,ROLE_B,ROLE_C”)
 *@param authorityString 逗号分隔的字符串
 *@return 通过标记字符串创建的权限
/
AuthorityUtils.commaSeparatedStringToAuthorityList("admin")

自定义登录请求

不使用springsecurity提供的默认登陆界面

1 自定义前端登录页

(template.login.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<h2>欢迎登录</h2>
<form action="/auth/login" method="post">
    <input name="username" type="text" placeholder="请输入用户名.."><br/>
    <input name="password" type="password" placeholder="请输入密码.."><br/>
    <input type="submit" value="登录">
</form>
</body>
</html>

2 自定义首页

(template.index.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MAIN首页</title>
</head>
<body>
<h1>欢迎来到首页</h1>
</body>
</html>

3 在控制器类中添加跳转路径

@Controller
public class UserController {

    // 登录测试
	...

    // 登录页,跳转到/templates/login.html页面
    @GetMapping("/login")
    public String login() {
        return "login";
    }

    // 首页,跳转到/templates/index.html页面
    @GetMapping("/index")
    public String index() {
        return "index";
    }
}    

4 修改拦截配置

修改MySecurityConfig中configure方法

(config.MySecurityConfig.java)

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
	...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                // 表单认证
                .formLogin()
                // 登录页
                .loginPage("/login")
                // 登录表单提交地址
                .loginProcessingUrl("/auth/login")
                .and()
                // 身份认证请求
                .authorizeRequests()
                // URL路径匹配
                .antMatchers("/login").permitAll()
                // 任意请求
                .anyRequest()
                // 身份认证
                .authenticated();

    }
}

loginProcessingUrl("/auth/login")中定义了表单提交地址,但在控制器UserController中并没有对应的请求路径,SpringSecutity默认拦截所有请求,并将URL 302重定向到/login默认登录页,使用默认的用户名密码即可登录。

自定义登录请求状态

方式一:继承接口实现

1 自定义登录成功类

(handler.MyAuthenticationSuccessHandler.java)

/**
 * 继承接口,用于处理成功的用户身份验证的策略
 */
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private static final Logger logger = LoggerFactory.getLogger(UserDetailsService.class);

    // 提供了读取和写入JSON的功能,可以与基本pojo类进行交互,也可以与通用JSON树模型进行交互,还提供了执行转换的相关功能。
    @Autowired
    private ObjectMapper objectMapper;

    // 当用户已成功通过身份验证时调用。
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");
        response.setContentType("application/json;charset=utf-8");
        // writeValueAsString:将java对象序列化为字符串
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

2 自定义登录失败类

(handler.MyAuthenticationFailureHandler.java)

@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private static final Logger logger = LoggerFactory.getLogger(UserDetailsService.class);

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        logger.info("登录失败");
        // http状态,200,成功
        response.setStatus(HttpStatus.OK.value());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception));
    }
}

方式二:修改MySecurityConfig的配置方法

1 添加登录成功和失败的处理方法

(config.MySecurityConfig.java)

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable()        
        .formLogin()
        .loginPage("/login")
        .loginProcessingUrl("/auth/login")
        // 登陆成功处理器
        .successHandler(new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter writer = response.getWriter();
                ObjectMapper om = new ObjectMapper();
                String successMsg = om.writeValueAsString(om.writeValueAsString(authentication));
                writer.write(successMsg);
                writer.flush();
                writer.close();
            }
        })
        // 登陆失败处理器
        .failureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                resp.setContentType("application/json;charset=utf-8");
                PrintWriter writer = resp.getWriter();
                writer.write(new ObjectMapper().writeValueAsString(e));
                writer.flush();
                writer.close();
            }
        })
        .and()
        .authorizeRequests()
        .antMatchers("/login").permitAll()
        .anyRequest()        
        .authenticated();

}

获取当前用户信息

(controller.UserController.java)

@Controller
public class UserController {
	...

    // 当前用户信息
    @GetMapping("/info")
    @ResponseBody
    public Object getCurrentUser(Authentication authentication) {
        return authentication;
    }
}

测试

启动项目,访问/info,登录成功,检查F12