Spring Security 快速入门

一、简介

Spring Security 是 Spring 家族中的一个安全管理框架,实际上,在 Spring Boot 出现之前,Spring Security 就已经发展了多年了,但是使用的并不多,安全管理这个领域,一直是 Shiro 的天下。

相对于 Shiro,在 SSM/SSH 中整合 Spring Security 都是比较麻烦的操作,所以,Spring Security 虽然功能比 Shiro 强大,但是使用反而没有 Shiro 多(Shiro 虽然功能没有 Spring Security 多,但是对于大部分项目而言,Shiro 也够用了)。

自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了 自动化配置方案,可以零配置使用 Spring Security。

因此,一般来说,常见的安全管理技术栈的组合是这样的:

  • SSM + Shiro
  • Spring Boot/Spring Cloud + Spring Security

注意,这只是一个推荐的组合而已,如果单纯从技术上来说,无论怎么组合,都是可以运行的。

我们来看下具体使用。

二、实战

1、创建项目

在 Spring Boot 中使用 Spring Security 非常容易,引入依赖即可:
file

pom.xml 中的 Spring Security 依赖:

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

只要加入依赖,项目的所有接口都会被自动保护起来。

2、初次体验

我们创建一个 HelloController:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

访问 /hello ,需要登录之后才能访问。
file

当用户从浏览器发送请求访问 /hello 接口时,服务端会返回 302 响应码,让客户端重定向到 /login 页面,用户在 /login 页面登录,登陆成功之后,就会自动跳转到 /hello 接口。

另外,也可以使用 POSTMAN 来发送请求,使用 POSTMAN 发送请求时,可以将用户信息放在请求头中(这样可以避免重定向到登录页面):

file

通过以上两种不同的登录方式,可以看出,Spring Security 支持两种不同的认证方式:

  • 可以通过 form 表单来认证
  • 可以通过 HttpBasic 来认证

3.用户名配置

默认情况下,登录的用户名是 user ,密码则是项目启动时随机生成的字符串,可以从启动的控制台日志中看到默认密码:

这个随机生成的密码,每次启动时都会变。对登录的用户名/密码进行配置,有三种不同的方式:

在 application.properties 中进行配置
通过 Java 代码配置在内存中
通过 Java 从数据库中加载
前两种比较简单,第三种代码量略大,本文就先来看看前两种,第三种后面再单独写文章介绍,也可以参考我的微人事项目。

3.1 配置文件配置用户名/密码
可以直接在 application.properties 文件中配置用户的基本信息:

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

配置完成后,重启项目,就可以使用这里配置的用户名/密码登录了。

Spring Security 中提供了 BCryptPasswordEncoder 密码编码工具,可以非常方便的实现密码的加密加盐,相同明文加密出来的结果总是不同,这样就不需要用户去额外保存盐的字段了,这一点比 Shiro 要方便很多。

4.登录配置

对于登录接口,登录成功后的响应,登录失败后的响应,我们都可以在 WebSecurityConfigurerAdapter 的实现类中进行配置。例如下面这样:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    VerifyCodeFilter verifyCodeFilter;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
        http
        .authorizeRequests()//开启登录配置
        .antMatchers("/hello").hasRole("admin")//表示访问 /hello 这个接口,需要具备 admin 这个角色
        .anyRequest().authenticated()//表示剩余的其他接口,登录之后就能访问
        .and()
        .formLogin()
        //定义登录页面,未登录时,访问一个需要登录之后才能访问的接口,会自动跳转到该页面
        .loginPage("/login_p")
        //登录处理接口
        .loginProcessingUrl("/doLogin")
        //定义登录时,用户名的 key,默认为 username
        .usernameParameter("uname")
        //定义登录时,用户密码的 key,默认为 password
        .passwordParameter("passwd")
        //登录成功的处理器
        .successHandler(new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                    resp.setContentType("application/json;charset=utf-8");
                    PrintWriter out = resp.getWriter();
                    out.write("success");
                    out.flush();
                }
            })
            .failureHandler(new AuthenticationFailureHandler() {
                @Override
                public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException exception) throws IOException, ServletException {
                    resp.setContentType("application/json;charset=utf-8");
                    PrintWriter out = resp.getWriter();
                    out.write("fail");
                    out.flush();
                }
            })
            .permitAll()//和表单登录相关的接口统统都直接通过
            .and()
            .logout()
            .logoutUrl("/logout")
            .logoutSuccessHandler(new LogoutSuccessHandler() {
                @Override
                public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                    resp.setContentType("application/json;charset=utf-8");
                    PrintWriter out = resp.getWriter();
                    out.write("logout success");
                    out.flush();
                }
            })
            .permitAll()
            .and()
            .httpBasic()
            .and()
            .csrf().disable();
    }
}

我们可以在 successHandler 方法中,配置登录成功的回调,如果是前后端分离开发的话,登录成功后返回 JSON 即可,同理,failureHandler 方法中配置登录失败的回调,logoutSuccessHandler 中则配置注销成功的回调。

三、Spring Security基本原理

1、过滤器链

SpringSecurity 本质是一个过滤器链,有很多过滤器,通过查看源码,来查看三个过滤器。

代码底层流程:重点看三个过滤器:
FilterSecurityInterceptor:是一个方法级的权限过滤器,基本位于过滤链的最底部。
/springframework/security/spring-security-web/5.3.4.RELEASE/spring-security-web-5.3.4.RELEASE.jar!/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.class

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.web.access.intercept;

import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import org.springframework.security.access.SecurityMetadataSource;
import org.springframework.security.access.intercept.AbstractSecurityInterceptor;
import org.springframework.security.access.intercept.InterceptorStatusToken;
import org.springframework.security.web.FilterInvocation;

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
  private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";
  private FilterInvocationSecurityMetadataSource securityMetadataSource;
  private boolean observeOncePerRequest = true;

  public FilterSecurityInterceptor() {
  }

  public void init(FilterConfig arg0) {
  }

  public void destroy() {
  }

  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    FilterInvocation fi = new FilterInvocation(request, response, chain);
    this.invoke(fi);
  }

  public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() {
    return this.securityMetadataSource;
  }

  public SecurityMetadataSource obtainSecurityMetadataSource() {
    return this.securityMetadataSource;
  }

  public void setSecurityMetadataSource(FilterInvocationSecurityMetadataSource newSource) {
    this.securityMetadataSource = newSource;
  }

  public Class<?> getSecureObjectClass() {
    return FilterInvocation.class;
  }

  public void invoke(FilterInvocation fi) throws IOException, ServletException {
    if (fi.getRequest() != null && fi.getRequest().getAttribute("__spring_security_filterSecurityInterceptor_filterApplied") != null && this.observeOncePerRequest) {
      fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
    } else {
      if (fi.getRequest() != null && this.observeOncePerRequest) {
        fi.getRequest().setAttribute("__spring_security_filterSecurityInterceptor_filterApplied", Boolean.TRUE);
      }

     // 先判断之前的过滤器是否放行,如果放行则继续执行下边的过滤器
      InterceptorStatusToken token = super.beforeInvocation(fi);

      try {
            // 继续执行过滤
        fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
      } finally {
        super.finallyInvocation(token);
      }

      super.afterInvocation(token, (Object)null);
    }

  }

  public boolean isObserveOncePerRequest() {
    return this.observeOncePerRequest;
  }

  public void setObserveOncePerRequest(boolean observeOncePerRequest) {
    this.observeOncePerRequest = observeOncePerRequest;
  }
}

super.beforeInvocation(fi) 表示查看之前的 filter 是否通过。
fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); 表示真正的调用后台的服务。

ExceptionTranslationFilter:是个异常过滤器,用来处理在认证授权过程中抛出的异常。

file

UsernamePasswordAuthenticationFilter:对/login 的POST 请求做拦截,校验表单中用户名,密码。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.web.authentication;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

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 = "username";
  private String passwordParameter = "password";
  private boolean postOnly = true;

  public UsernamePasswordAuthenticationFilter() {
    super(new AntPathRequestMatcher("/login", "POST"));
  }

  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    if (this.postOnly && !request.getMethod().equals("POST")) {
      throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
    } else {
      String username = this.obtainUsername(request);
      String password = this.obtainPassword(request);
      if (username == null) {
        username = "";
      }

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

      username = username.trim();
      UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
      this.setDetails(request, authRequest);
      return this.getAuthenticationManager().authenticate(authRequest);
    }
  }

  @Nullable
  protected String obtainPassword(HttpServletRequest request) {
    return request.getParameter(this.passwordParameter);
  }

  @Nullable
  protected String obtainUsername(HttpServletRequest request) {
    return request.getParameter(this.usernameParameter);
  }

  protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
    authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
  }

  public void setUsernameParameter(String usernameParameter) {
    Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
    this.usernameParameter = usernameParameter;
  }

  public void setPasswordParameter(String passwordParameter) {
    Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
    this.passwordParameter = passwordParameter;
  }

  public void setPostOnly(boolean postOnly) {
    this.postOnly = postOnly;
  }

  public final String getUsernameParameter() {
    return this.usernameParameter;
  }

  public final String getPasswordParameter() {
    return this.passwordParameter;
  }
}

2、过滤器加载过程

过滤器如何进行加载的?

  • 1、使用SpringSecurity配置过滤器
    • DelegatingFilterProxy

file

3、两个重要的接口

UserDetailsService 接口

当什么也没配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。所以,我们要通过自定义逻辑控制认证逻辑。

如果需要自定义逻辑时,只需要实现 UserDetailsService 接口即可。接口定义如下:
UserDetailsService.class

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.core.userdetails;

public interface UserDetailsService {
  UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

实现范例:
com/ruoyi/framework/security/service/UserDetailsServiceImpl.java

package com.ruoyi.framework.security.service;

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.stereotype.Service;
import com.ruoyi.common.enums.UserStatus;
import com.ruoyi.common.exception.BaseException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.service.ISysUserService;

/**
 * 用户验证处理
 *
 * @author ruoyi
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user))
        {
            log.info("登录用户:{} 不存在.", username);
            throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
        }
        else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("登录用户:{} 已被删除.", username);
            throw new BaseException("对不起,您的账号:" + username + " 已被删除");
        }
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("登录用户:{} 已被停用.", username);
            throw new BaseException("对不起,您的账号:" + username + " 已停用");
        }

        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user)
    {
        return new LoginUser(user, permissionService.getMenuPermission(user));
    }
}

PasswordEncoder 接口

public interface PasswordEncoder {
// 表示把参数按照特定的解析规则进行解析
String encode(CharSequence rawPassword);

// 表示验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回true;如果不匹配,则返回false。第一个参数表示需要被解析的密码,第二个参数表示存储的密码。
boolean matches(CharSequence rawPassword, String encodedPassword);

// 表示如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回false。默认返回false。
 default boolean upgradeEncoding(String encodedPassword) {
    return false;
  }
}

加密示例:
BCryptPasswordEncoder.class

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.crypto.bcrypt;

import java.security.SecureRandom;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.crypto.password.PasswordEncoder;

public class BCryptPasswordEncoder implements PasswordEncoder {
  private Pattern BCRYPT_PATTERN;
  private final Log logger;
  private final int strength;
  private final SecureRandom random;

  public BCryptPasswordEncoder() {
    this(-1);
  }

  public BCryptPasswordEncoder(int strength) {
    this(strength, (SecureRandom)null);
  }

  public BCryptPasswordEncoder(int strength, SecureRandom random) {
    this.BCRYPT_PATTERN = Pattern.compile("\\A\\$2a?\\$\\d\\d\\$[./0-9A-Za-z]{53}");
    this.logger = LogFactory.getLog(this.getClass());
    if (strength == -1 || strength >= 4 && strength <= 31) {
      this.strength = strength;
      this.random = random;
    } else {
      throw new IllegalArgumentException("Bad strength");
    }
  }

  public String encode(CharSequence rawPassword) {
    String salt;
    if (this.strength > 0) {
      if (this.random != null) {
        salt = BCrypt.gensalt(this.strength, this.random);
      } else {
        salt = BCrypt.gensalt(this.strength);
      }
    } else {
      salt = BCrypt.gensalt();
    }

    return BCrypt.hashpw(rawPassword.toString(), salt);
  }

  public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (encodedPassword != null && encodedPassword.length() != 0) {
      if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
        this.logger.warn("Encoded password does not look like BCrypt");
        return false;
      } else {
        return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
      }
    } else {
      this.logger.warn("Empty encoded password");
      return false;
    }
  }
}

com/ruoyi/framework/config/SecurityConfig.java

/**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

file


相关文章:
手把手带你入门 Spring Security!
Spring Security 入门原理及实战

为者常成,行者常至