Spring Security详解 顶

2023-09-20 20:11:46 浏览数 (1)

要使用Spring Security,首先当然是得要加上依赖

代码语言:javascript复制
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

这个时候我们不在配置文件中做任何配置,随便写一个Controller

代码语言:javascript复制
@RestController
public class TestController {
    @GetMapping("/hello")
    public String request() {
        return "hello";
    }
}

启动项目,我们会发现有这么一段日志

2020-01-05 01:57:16.482 INFO 3932 --- main .s.s.UserDetailsServiceAutoConfiguration :

Using generated security password: 1f0b4e14-1d4c-4dc8-ac32-6d84524a57dc

此时表示Security生效,默认对项目进行了保护,我们访问该Controller中的接口,会见到如下登录界面

这里面的用户名和密码是什么呢?此时我们需要输入用户名:user,密码则为之前日志中的"1f0b4e14-1d4c-4dc8-ac32-6d84524a57dc",输入之后,我们可以看到此时可以正常访问该接口

在老版本的Springboot中(比如说Springboot 1.x版本中),可以通过如下方式来关闭Spring Security的生效,但是现在Springboot 2中已经不再支持

代码语言:javascript复制
security:
  basic:
    enabled: false

当然像这种什么都不配置的情况下,其实是使用的表单认证,现在我们可以把认证方式改成HttpBasic认证(关于HTTP的几种认证方式可以参考HTTP协议整理 中的HTTP的常见认证方式)。

此时我们需要在项目中加入这样一个配置类

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置的适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic() //允许Basic登录
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated();   //都需要身份认证
    }
}

此时重启项目,在访问/hello,界面如下

输入用户名,密码(方法与之前相同),则可以正常访问该接口。当然在这里,我们也可以改回允许表单登录。

代码语言:javascript复制
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin() //允许表单登录
            .and()
            .authorizeRequests() //对请求进行授权
            .anyRequest()  //任何请求
            .authenticated();   //都需要身份认证
}

这样又变回跟之前默认不配置一样了。

SpringSecutiry基本原理

由上图我们可以看到,Spring Security其实就是一个过滤器链,它里面有很多很多的过滤器,就图上的第一个过滤器UsernamePasswordAuthenticationFilter是用来做表单认证过滤的;如果我们没有配置表单认证,而是Basic认证,则第二个过滤器BasicAuthenticationFilter会发挥作用。最后一个FilterSecurityInterceptor则是用来最后一个过滤器,它的作用是用来根据前面的过滤器是否生效以及生效的结果来判断你的请求是否可以访问REST接口。如果无法通过FilterSecurityInterceptor的判断的情况下,会抛出异常。而ExceptionTranslationFIlter会捕获抛出的异常来进行相应的处理。

过滤器的使用

现在我们自己来写一个过滤器,看看过滤器是如何使用的,现在我们要看一下接口的调用时间(该Filter接口为javax.servlet.Filter)

代码语言:javascript复制
@Slf4j
@Component
public class TimeFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("time filter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("time filter start");
        long start = System.currentTimeMillis();
        filterChain.doFilter(servletRequest,servletResponse);
        log.info("time filter: "   (System.currentTimeMillis() - start));
        log.info("time filter finish");
    }

    @Override
    public void destroy() {
        log.info("time filter destroy");
    }
}

启动项目,我们可以看到这样一段输出

2020-01-05 09:02:17.646 INFO 526 --- main c.g.secritydemo.config.TimeFilter : time filter init

说明过滤器已经开始工作。

当我们调用Controller方法之后,可以看到如下日志

2020-01-05 09:02:56.910 INFO 526 --- nio-8080-exec-8 c.g.secritydemo.config.TimeFilter : time filter start

2020-01-05 09:02:56.920 INFO 526 --- nio-8080-exec-8 c.g.secritydemo.config.TimeFilter : time filter: 10

2020-01-05 09:02:56.920 INFO 526 --- nio-8080-exec-8 c.g.secritydemo.config.TimeFilter : time filter finish

在Spring MVC中,我们是把过滤器配置到web.xml中,但是在Spring boot中是没有web.xml的,如果我们写的过滤器或者第三方过滤器没有使用依赖注入,即这里不使用@Component注解,该如何使得该过滤器正常使用的。

代码语言:javascript复制
@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean timeFilter() {
        //初始化一个过滤器注册器
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        TimeFilter timeFilter = new TimeFilter();
        //将自定义的过滤器或者第三方过滤器注册到过滤器链中
        registrationBean.setFilter(timeFilter);

        List<String> urls = new ArrayList<>();
        urls.add("/*");
        //该过滤器对所有的url起作用,但你也可以配置专门的url进行过滤
        registrationBean.setUrlPatterns(urls);
        return registrationBean;
    }
}

经过以上的设置,我们就可以将自定义过滤器或者第三方过滤器加入到过滤器链中了。

现在我们回到SpringSecutiry的过滤器中,先来看一下FilterSecurityInterceptor

代码语言:javascript复制
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 void init(FilterConfig arg0) throws ServletException {
   }

   public void destroy() {
   }

   public void doFilter(ServletRequest request, ServletResponse response,
         FilterChain chain) throws IOException, ServletException {
      FilterInvocation fi = new FilterInvocation(request, response, chain);
      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(FILTER_APPLIED) != null)
            && observeOncePerRequest) {
         //非首次请求正常处理
         fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
      }
      else {
         //当首次请求的时候,request的FILTER_APPLIED属性是null的
         if (fi.getRequest() != null && observeOncePerRequest) {
            //给request设置FILTER_APPLIED属性
            fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
         }
         //在调用RestAPI前对认证授权进行检查
         InterceptorStatusToken token = super.beforeInvocation(fi);

         try {
            //调真正的RestAPI服务
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
         }
         finally {
            super.finallyInvocation(token);
         }

         super.afterInvocation(token, null);
      }
   }

   public boolean isObserveOncePerRequest() {
      return observeOncePerRequest;
   }

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

InterceptorStatusToken token = super.beforeInvocation(fi); //在调用RestAPI前对认证授权进行检查

由该段代码可以看到,要想完成RestAPI的请求就会对之前的认证进行检查,如果通过检查,之后的访问就会正常访问,不再检查。

异常捕获ExceptionTranslationFIlter

代码语言:javascript复制
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {
   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;

   try {
      chain.doFilter(request, response);

      logger.debug("Chain processed normally");
   }
   catch (IOException ex) {
      throw ex;
   }
   catch (Exception ex) {
      //捕获到异常后进行异常处理
      Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
      RuntimeException ase = (AuthenticationException) throwableAnalyzer
            .getFirstThrowableOfType(AuthenticationException.class, causeChain);

      if (ase == null) {
         ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
               AccessDeniedException.class, causeChain);
      }

      if (ase != null) {
         if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
         }
         handleSpringSecurityException(request, response, chain, ase);
      }
      else {
         // Rethrow ServletExceptions and RuntimeExceptions as-is
         if (ex instanceof ServletException) {
            throw (ServletException) ex;
         }
         else if (ex instanceof RuntimeException) {
            throw (RuntimeException) ex;
         }

         // Wrap other Exceptions. This shouldn't actually happen
         // as we've already covered all the possibilities for doFilter
         throw new RuntimeException(ex);
      }
   }
}

表单登录UsernamePasswordAuthenticationFilter,我们来看一下它的继承图

由图可以看到,它是继承了实现Filter接口的父类的子类。doFilter方法在其父类AbstractAuthenticationProcessingFilter中

代码语言:javascript复制
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {

   HttpServletRequest request = (HttpServletRequest) req;
   HttpServletResponse response = (HttpServletResponse) res;
   //当要求的认证方式不是表单认证时,过滤器相后传递,直接返回
   if (!requiresAuthentication(request, response)) {
      chain.doFilter(request, response);

      return;
   }

   if (logger.isDebugEnabled()) {
      logger.debug("Request is to process authentication");
   }

   Authentication authResult;

   try {
      //开始进行认证请求处理,attemptAuthentication为一个抽象方法,会在UsernamePasswordAuthenticationFilter中实现
      authResult = attemptAuthentication(request, response);
      if (authResult == null) {
         // return immediately as subclass has indicated that it hasn't completed
         // authentication
         return;
      }
      sessionStrategy.onAuthentication(authResult, request, response);
   }
   catch (InternalAuthenticationServiceException failed) {
      logger.error(
            "An internal error occurred while trying to authenticate the user.",
            failed);
      unsuccessfulAuthentication(request, response, failed);

      return;
   }
   catch (AuthenticationException failed) {
      // Authentication failed
      unsuccessfulAuthentication(request, response, failed);

      return;
   }

   // Authentication success
   if (continueChainBeforeSuccessfulAuthentication) {
      chain.doFilter(request, response);
   }

   successfulAuthentication(request, response, chain, authResult);
}
代码语言:javascript复制
protected boolean requiresAuthentication(HttpServletRequest request,
      HttpServletResponse response) {
   return requiresAuthenticationRequestMatcher.matches(request);
}

而它自己只会处理"/login", "POST"这样一个请求

代码语言:javascript复制
public UsernamePasswordAuthenticationFilter() {
   super(new AntPathRequestMatcher("/login", "POST"));
}
代码语言:javascript复制
protected AbstractAuthenticationProcessingFilter(
      RequestMatcher requiresAuthenticationRequestMatcher) {
   Assert.notNull(requiresAuthenticationRequestMatcher,
         "requiresAuthenticationRequestMatcher cannot be null");
   this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;
}

在收到这样一个请求后,会拿到用户名,密码进行一个登录

代码语言:javascript复制
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);

   // Allow subclasses to set the "details" property
   setDetails(request, authRequest);

   return this.getAuthenticationManager().authenticate(authRequest);
}

自定义用户认证逻辑

  • 处理用户信息获取逻辑

自定义处理用户信息获取的是通过UserDetailsService这个接口来实现的,该接口定义如下

代码语言:javascript复制
public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

现在我们来做一个自定义的实现,我们先在SecrityConfig配置类中添加一个加密器,因为新版本的SpringSecrity是不允许明文密码的(老版本Springboot 1.x的允许明文密码),所以我们要对密码进行一个加密。

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .and()
                .authorizeRequests() //对请求进行授权
                .anyRequest()  //任何请求
                .authenticated();   //都需要身份认证
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
代码语言:javascript复制
@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:"   username);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //其第三个属性为用户权限,后续说明
        return new User(username,passwordEncoder.encode("123456")
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

其中UserDetails为一个接口,如果我们自己在数据库中取数,登录用户类需要实现该接口

代码语言:javascript复制
//该接口封装了SpringSecurity登录所需要的所有信息
public interface UserDetails extends Serializable {
   //权限信息
   Collection<? extends GrantedAuthority> getAuthorities();
   //获取密码
   String getPassword();
   //获取用户名
   String getUsername();
   //账户是否过期(true未过期,false过期)
   boolean isAccountNonExpired();
   //账户是否锁定
   boolean isAccountNonLocked();
   //密码是否过期
   boolean isCredentialsNonExpired();
   //账户是否可用
   boolean isEnabled();
}

除了前三个接口方法,后面四个可根据你的项目都实际情况酌情实现和设定,它们不是必须的,在不需要使用的情况下可以直接设定为true.一般我们认为锁定的用户可以被恢复,而不可用用户不能被恢复。

此时重启项目,访问Controller接口,一样会出现输入用户名,密码的界面,但此处与之前不同的地方为用户名可以是任意的,而并非user了,密码则也不是启动日志中出现的一长串字符串,而且现在启动日志中也不会出现这个字符串了,密码就是我们设置的123456

  • 用户校验逻辑

现在我们来改写MyUserDetailsService,使返回的用户被锁定

代码语言:javascript复制
@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:"   username);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //第三个参数为是否可用,第四个参数为账户是否过期,第五个参数为密码是否过期,第六个参数为账户是否被锁定
        //其第七个属性为用户权限
        return new User(username,passwordEncoder.encode("123456")
                ,true,true,true,false
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

现在我们将三参构造器变成一个七参构造器,重新启动项目。访问Controller接口,输入用户名(任意),密码(123456)后会出现如下提示

这里需要说明的是BCryptPasswordEncoder对同一个密码每次加密后的密文都是不一样的,比如我们对加密后对密文进行打印

代码语言:javascript复制
@Service
@Slf4j
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;
    /**
     * 根据用户名查找用户信息,该用户信息可以从数据库中取出,
     * 然后拼装成UserDetails对象
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:"   username);
        String password = passwordEncoder.encode("123456");
        log.info("密码:"   password);
        //该User类是SpringSecurity自带实现UserDetails接口的一个用户类
        //使用加密工具对密码进行加密
        //第三个参数为是否可用,第四个参数为账户是否过期,第五个参数为密码是否过期,第六个参数为账户是否被锁定
        //其第七个属性为用户权限
        return new User(username,password
                ,true,true,true,true
                , AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

经过接口访问后,可以看到这样一段日志

2020-01-05 20:38:53.898 INFO 851 --- nio-8080-exec-5 c.g.s.service.MyUserDetailsService : 密码:$2a$10$Wtrf9TkrHHooNo6Fv1vRqOjJmxayOMBatJoSfelHjBWajj3JOdwly

然后我们换一个浏览器访问该接口

2020-01-05 20:43:22.520 INFO 851 --- nio-8080-exec-9 c.g.s.service.MyUserDetailsService : 密码:$2a$10$eGB0bYvQiGiZtr79IFlms.16Q8FSoQxMKSxJK00uQM9PfeKy5xnHu

同样都是123456,加密出来却不一样。这主要要从BCryptPasswordEncoder加密和密码比对的两个方法来看

代码语言:javascript复制
private final int strength; //密码长度
private final SecureRandom random; //随机种子

有关SecureRandom的说明可以参考使用Random来生成随机数的危险性

代码语言:javascript复制
public String encode(CharSequence rawPassword) {
   String salt;
   if (strength > 0) {
      if (random != null) {
         salt = BCrypt.gensalt(strength, random);
      }
      else {
         salt = BCrypt.gensalt(strength);
      }
   }
   else {
      //生成一个随机加盐的前缀,而使用SecureRandom来生成随机盐是较为安全的
      salt = BCrypt.gensalt();
   }
   //根据随机盐与密码进行一次SHA256的运算并在之前拼装随机盐得到最终密码
   //因为每次加密,随机盐是不同的,不然不叫随机了,所以加密出来的密文也不相同
   return BCrypt.hashpw(rawPassword.toString(), salt);
}

public boolean matches(CharSequence rawPassword, String encodedPassword) {
   if (encodedPassword == null || encodedPassword.length() == 0) {
      logger.warn("Empty encoded password");
      return false;
   }

   if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
      logger.warn("Encoded password does not look like BCrypt");
      return false;
   }
   //密码比对的时候,先从密文中拿取随机盐,而不是重新生成新的随机盐
   //再通过该随机盐与要比对的密码进行一次Sha256的运算,再在前面拼装上该随机盐与密文进行比较
   return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}

这里面的重点在于密文没有掌握在攻击者手里,是安全的,也就是攻击者无法得知随机盐是什么,而SecureRandom产生伪随机的条件非常苛刻,一般是一些计算机内部的事件。但是这是一种慢加密方式,对于要登录吞吐量较高的时候无法满足需求,具体可以参考Springboot 2-OAuth 2修改登录加密方式 ,但要说明的是MD5已经不安全了,可以被短时间内(小时记,也不是几秒内吧)暴力破解,个中取舍由开发者决定。

自定义登录界面

现在我们要用自己写的html文件来代替默认的登录界面,在资源文件夹(Resources)下新建一个Resources文件夹。在该文件夹下新建一个signIn.html的文件。html代码如下

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h2>标准登录页面</h2>
    <h3>表单登录</h3>
    <form action="/authentication/form" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="username"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="password"></td>
            </tr>
            <tr>
                <td colspan="2"><button type="submit">登录</button> </td>
            </tr>
        </table>
    </form>
</body>
</html>

修改SecrityConfig如下

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/signIn.html") //设置表单登录页
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对signIn.html页面放行
                .antMatchers("/signIn.html").permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重新启动项目,访问/hello接口,被转向到我们自己建立的html登录页面

随便输入用户名,密码123456后,/hello接口访问成功。

处理不同类型的请求

现在我们将登录流程改成上图所示。

加配置项(该配置项前两个可以任意设置,即gj.secrity),该设置为用户为html访问无权限时跳转的配置登录页/demo-signIn.html,当然我们还有一个主登录页/signIn.html

代码语言:javascript复制
gj:
  secrity:
    browser:
      loginPage: /demo-signIn.html

该demo-signIn.html文件内容如下

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页</title>
</head>
<body>
    <h2>demo登录页</h2>
</body>
</html>

设置两个属性类来获取配置登录页的属性

代码语言:javascript复制
@Data
public class BrowserProperties {
    //当配置登录页取不到值的时候,使用主登录页
    private String loginPage = "/signIn.html";
}
代码语言:javascript复制
@ConfigurationProperties(prefix = "gj.secrity")
@Data
public class SecrityProperties {
    private BrowserProperties browser = new BrowserProperties();
}
代码语言:javascript复制
/**
 * 使SecrityProperties配置类生效
 */
@Configuration
@EnableConfigurationProperties(SecrityProperties.class)
public class SecrityCoreConfig {
}

设置一个认证Controller的返回类型

代码语言:javascript复制
/**
 * 认证Controller返回的结果类型
 */
@Data
@AllArgsConstructor
public class SimpleResponse {
    private Object content;
}

添加一个认证Controller来判断是html的请求还是Restful接口请求

代码语言:javascript复制
@Slf4j
@RestController
public class AuthenticationController {
    //请求缓存
    private RequestCache requestCache = new HttpSessionRequestCache();
    //跳转工具
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    @Autowired
    private SecrityProperties secrityProperties;

    /**
     * 当需要身份认证时跳转到这里
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/authencation/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponse requireAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws IOException {
        //获取引发跳转的请求
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            log.info("引发跳转的请求是:"   targetUrl);
            if (StringUtils.endsWithIgnoreCase(targetUrl,".html")) {
                //如果是html请求跳转过来的则跳转到配置登录页,如果没有配置登录页则跳转到标准登录页
                redirectStrategy.sendRedirect(request,response,secrityProperties.getBrowser().getLoginPage());
            }
        }

        return new SimpleResponse("访问的服务需要身份认证,请引导用户到登录页");
    }
}

修改SecrityConfig,来达到跟/authencation/require路径一致

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require") //设置登录处理请求路径
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重启项目,当我们访问/hello接口时,登录Controller不会进行登录跳转,而是会返回一个状态401的错误提示

但如果我们访问的是例如/index.html时,登录Controller会将其进行跳转到配置登录页

现在我们将配置中的内容注释掉,重启项目

代码语言:javascript复制
#gj:
#  secrity:
#    browser:
#      loginPage: /demo-signIn.html

此时再访问/index.html时,则会跳转到主登录页

自定义登录成功处理

要实现登录成功处理,我们只需要实现AuthenticationSuccessHandler接口,该接口的定义如下

代码语言:javascript复制
public interface AuthenticationSuccessHandler {

   /**
    * 登录成功后被调用
    */
   void onAuthenticationSuccess(HttpServletRequest request,
         HttpServletResponse response, Authentication authentication)
         throws IOException, ServletException;

}

我们使用一个实现类来实现该接口,登录成功后返回authentication的json字符串

代码语言:javascript复制
@Slf4j
@Component("loginAuthenticationSuccessHandler")
public class LoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登录成功");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(authentication));
    }
}

修改SecrityConfig

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private AuthenticationSuccessHandler loginAuthenticationSuccessHandler;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require")
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                //添加自定义登录成功处理器
                .successHandler(loginAuthenticationSuccessHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重启项目,当我们用rebot,123456登录后,返回结果如下

这里面包含了所有的用户认证信息,Authentication为一个接口,定义如下

代码语言:javascript复制
public interface Authentication extends Principal, Serializable {
   //获取登录用户权限
   Collection<? extends GrantedAuthority> getAuthorities();
   //获取密码
   Object getCredentials();
   //获取登录详情(包含认证请求的IP以及SessionID)
   Object getDetails();
   //UserDetailsService接口中的内容
   Object getPrincipal();
   //是否已登录
   boolean isAuthenticated();
   //设置是否登录验证成功
   void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

根据该接口的含义,我们可以来解读返回的内容

  • "authenticated":true 登录成功
  • "authorities":{"authority":"admin"} 权限为admin
  • "remoteAddress":"127.0.0.1" 请求IP为127.0.0.1
  • "sessionId":"72784404B030C3CBAF52B7FE133D30FB" sessionID
  • "name":"rebot" 登录名为rebot
  • "accountNonExpired":true 账户未过期
  • "accountNonLocked":true 账户未锁定
  • "credentialsNonExpired":true 密码未过期
  • "enabled":true 账户可用

自定义登录失败处理

要实现登录失败处理,我们只需要实现AuthenticationFailureHandler接口,该接口的定义如下

代码语言:javascript复制
public interface AuthenticationFailureHandler {

   /**
    * 登录失败后被调用
    */
   void onAuthenticationFailure(HttpServletRequest request,
         HttpServletResponse response, AuthenticationException exception)
         throws IOException, ServletException;
}

这其中AuthenticationException为登录失败的一个异常,它是一个抽象类,具体的子类有很多

这里每一个子类都代表一种登录错误的情况

现在我们来写一个AuthenticationFailureHandler接口的实现类,将登录异常给发送到前端

代码语言:javascript复制
@Slf4j
@Component("loginAuthenticationFailureHandler")
public class LoginAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登录失败");
        //修改默认登录状态200为500
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(exception));
    }
}

修改SecrityConfig

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecrityProperties secrityProperties;
    @Autowired
    private AuthenticationSuccessHandler loginAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //允许表单登录
                .loginPage("/authencation/require")
                //使用/authentication/form的url来处理表单登录请求
                .loginProcessingUrl("/authentication/form")
                //添加自定义登录成功处理器
                .successHandler(loginAuthenticationSuccessHandler)
                //添加自定义登录失败处理器
                .failureHandler(loginAuthenticationFailureHandler)
                .and()
                .authorizeRequests() //对请求进行授权
                //对/authencation/require以及配置页请求放行
                .antMatchers("/authencation/require",
                        secrityProperties.getBrowser().getLoginPage())
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重新启动项目,进行登录,并输入一个错误的密码后,结果如下所示

现在无论登录成功还是失败,返回的都是JSON,现在我们来将其修改成根据配置来决定是返回JSON还是重定向。

先添加一个登录成功的重定向页面index.html

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>欢迎</title>
</head>
<body>
    <h2>欢迎登录成功</h2>
</body>
</html>

增加配置

代码语言:javascript复制
gj:
  secrity:
    browser:
      loginType: REDIRECT

增加一个枚举类型

代码语言:javascript复制
public enum LoginType {
    REDIRECT,
    JSON;
}

修改BrowserProperties

代码语言:javascript复制
@Data
public class BrowserProperties {
    //当配置登录页取不到值的时候,使用主登录页
    private String loginPage = "/signIn.html";
    //当登录后的处理类型(跳转还是Json),默认为Json
    private LoginType loginType = LoginType.JSON;
}

现在我们需要给LoginAuthenticationSuccessHandler,LoginAuthenticationFailureHandler增加判断是返回JSon还是跳转的逻辑

代码语言:javascript复制
@Slf4j
@Component("loginAuthenticationSuccessHandler")
public class LoginAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登录成功");
        //如果配置的登录方式为Json
        if (LoginType.JSON.equals(secrityProperties.getBrowser().getLoginType())) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONObject.toJSONString(authentication));
        }else {
            //如果登录方式不为Json,则跳转到登录前访问的.html页面
            super.onAuthenticationSuccess(request,response,authentication);
        }
    }
}

这里SavedRequestAwareAuthenticationSuccessHandler是一个专门处理登录成功的包装器,我们可以来看一下它的继承图

由图中可以看到,他是实现了AuthenticationFailureHandler接口的SimpleUrlAuthenticationSuccessHandler实现类的子类。

代码语言:javascript复制
@Slf4j
@Component("loginAuthenticationFailureHandler")
public class LoginAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Autowired
    private SecrityProperties secrityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登录失败");
        //如果配置的登录方式为Json
        if (LoginType.JSON.equals(secrityProperties.getBrowser().getLoginType())) {
            //修改默认登录状态200为500
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONObject.toJSONString(exception));
        }else {
            //如果登录方式不为Json,则跳转到登录页判断的Controller接口
            super.onAuthenticationFailure(request,response,exception);
        }
    }
}

我们来看一下SimpleUrlAuthenticationSuccessHandler的继承图

由图可知,它就是实现了AuthenticationFailureHandler接口的实现类。

重新启动项目,这里需要说明的是如果不做配置,则结果跟之前返回JSon的情况一样,现在是做了配置的

如果登录成功,则跳转到index.html

如果登录失败,则跳转到/authencation/require的请求结果中

Spring Secrity OAuth 2

OAuth 2的整体结构如下图所示

要使用Spring OAuth 2需要添加依赖

代码语言:javascript复制
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

由于该依赖包含了

代码语言:javascript复制
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

所以spring-boot-starter-security可以不写。但由于spring-cloud-starter-oauth2属于Spring Cloud而不是Springboot的,所以我们还需要加上Spring CLoud的依赖(本人Springboot为2.1.9版本)

代码语言:javascript复制
<dependencyManagement>
   <dependencies>
      <dependency>
         <groupId>org.springframework.cloud</groupId>
         <artifactId>spring-cloud-dependencies</artifactId>
         <version>Greenwich.SR2</version>
         <type>pom</type>
         <scope>import</scope>
      </dependency>
   </dependencies>
</dependencyManagement>

授权码模式

增加配置

代码语言:javascript复制
security:
  oauth2:
    client:
      client-id: robetid
      client-secret: robetsceret

如果不进行以上配置,则每次启动会随机一个client-id以及client-secret

增加OAuth认证授权配置

代码语言:javascript复制
/**
 * @EnableAuthorizationServer允许开启认证中心
 * AuthorizationServerConfigurerAdapter为认证中心适配器
 */
@Configuration
@EnableAuthorizationServer
public class OAuthAuthenticationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 配置第三方应用详情信息
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() //在内存中设置clients,也可以在数据库中设置
                //clientid与配置相同
                .withClient("robetid")
                //clientsecret新版本Springboot必须加密,设置与配置相同
                .secret(passwordEncoder.encode("robetsceret"))
                //grant_type在授权吗模式下必须为authorization_code
                .authorizedGrantTypes("authorization_code","password")
                //跳转页面,获取code授权码用
                .redirectUris("http://example.com");
    }
}

修改SecrityConfig

代码语言:javascript复制
/**
 * WebSecurityConfigurerAdapter是Spring提供的对安全配置对适配器
 * 使用@EnableWebSecurity来开启Web安全
 */
@Configuration
@EnableWebSecurity
public class SecrityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 重写configure方法来满足我们自己的需求
     * 此处允许Basic登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() //基于表单认证
                .loginPage("/signIn.html")
                .loginProcessingUrl("/authentication/form")
                .and()
                .authorizeRequests() //对请求进行授权
                //对/oauth/**请求放行
                .antMatchers("/signIn.html","/oauth/**")
                .permitAll()
                .anyRequest()  //任何请求
                .authenticated()   //都需要身份认证
                .and()
                .csrf().disable(); //关闭跨站请求伪造防护
    }

    /**
     * 添加一个加密工具对bean,PasswordEncoder为接口
     * BCryptPasswordEncoder为实现类,也可以用其他加密实现类代替
     * 如MD5等
     * @return
     */
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

经过以上设置启动项目,访问获取授权码的url

http://127.0.0.1:8080/oauth/authorize?response_type=code&client_id=robetid&redirect_uri=http://example.com&scope=all

  • 此处response_type必须为code
  • client_id必须与配置项相同
  • redirect_uri与OAuthAuthenticationServerConfig中设置相同
  • scope为all表示所有范围

如果此时未登录,会进入登录界面,登录后进入授权界面,其中Approve为授权,Deny为取消授权

如果选择Deny,点击按钮,会进入跳转url,即example.com,但此时我们拿不到授权码

若授权Approve后,可以获取我们的授权码

其中WAbem8就为我们的授权码。

现在我们要通过该授权码拿取access_token,由于拿取token的接口为POST,所以使用postman工具进行获取

127.0.0.1:8080/oauth/token?grant_type=authorization_code&client_id=robetid&code=WAbem8&redirect_uri=http://example.com&scope=all

除了以上的参数设置外,还需要设置OAuth认证头,其中的Usernane和Password跟配置中相同

访问后可以获取访问其他接口的授权access_token

{

"access_token" : "5f2925bc-0d97-4390-bd14-e6803b2a43b5" ,

"token_type" : "bearer" ,

"expires_in" : 43199 ,

"scope" : "all"

}

0 人点赞