Spring Security---ONE

x33g5p2x  于2021-11-18 转载在 Spring  
字(24.2k)|赞(0)|评价(0)|浏览(683)

Http Basic登录认证的流程和原理

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
   
   @Override
   protected void configure(HttpSecurity http) throws Exception {
      http.httpBasic()//开启httpbasic认证
      .and()
      .authorizeRequests()
      .anyRequest()
      .authenticated();//所有请求都需要登录认证才能访问
   }

}

启动项目,在项目后台有这样的一串日志打印,冒号后面的就是默认密码。

Using generated security password: 0cc59a43-c2e7-4c21-a38c-0df8d1a6d624

我们可以通过浏览器进行登录验证,默认的用户名是user.(下面的登录框不是我们开发的,是HttpBasic模式自带的)

当然我们也可以通过application.yml指定配置用户名密码

spring:
    security:
      user:
        name: admin
        password: admin

  • 首先,HttpBasic模式要求传输的用户名密码使用Base64模式进行加密。如果用户名是 “admin” ,密码是“ admin”,则将字符串"admin:admin"使用Base64编码算法加密。加密结果可能是:YWtaW46YWRtaW4=。
  • 然后,在Http请求中使用Authorization作为一个Header,“Basic YWtaW46YWRtaW4=“作为Header的值,发送给服务端。(注意这里使用Basic+空格+加密串)
  • 服务器在收到这样的请求时,到达BasicAuthenticationFilter过滤器,将提取“ Authorization”的Header值,并使用用于验证用户身份的相同算法Base64进行解码。
  • 解码结果与登录验证的用户名密码匹配,匹配成功则可以继续过滤器后续的访问。

所以,HttpBasic模式真的是非常简单又简陋的验证模式,Base64的加密算法是可逆的,你知道上面的原理,分分钟就破解掉。我们完全可以使用PostMan工具,发送Http请求进行登录验证。

PasswordEncoder详解

Hash算法特别的地方在于它是一种单向算法,用户可以通过hash算法对某个数据生成一段特定长度的唯一hash值,却不能通过这个hash值逆向获取原始数据。因此Hash算法常用在不可还原的密码存储、数据完整性校验等领域。

那问题来了,密码只能单向加密不能解密,那如何校验密码的正确性?我们来看Spring Security中的接口PasswordEncoder ,并对这个问题进行解答。

PasswordEncoder 接口

PasswordEncoder 是Spring Scurity框架内处理密码加密与校验的接口。

public interface PasswordEncoder {
   String encode(CharSequence rawPassword);

   boolean matches(CharSequence rawPassword, String encodedPassword);

   default boolean upgradeEncoding(String encodedPassword) {
      return false;
   }
}

这个接口有三个方法

  • encode方法接受的参数是原始密码字符串,返回值是经过加密之后的hash值,hash值是不能被逆向解密的。这个方法通常在为系统添加用户,或者用户注册的时候使用。
  • matches方法是用来校验用户输入密码rawPassword,和加密后的hash值encodedPassword是否匹配。如果能够匹配返回true,表示用户输入的密码rawPassword是正确的,反之返回fasle。也就是说虽然这个hash值不能被逆向解密,但是可以判断是否和原始密码匹配。这个方法通常在用户登录的时候进行用户输入密码的正确性校验。
  • upgradeEncoding设计的用意是,判断当前的密码是否需要升级。也就是是否需要重新加密?需要的话返回true,不需要的话返回fasle。默认实现是返回false。

例如,我们可以通过如下示例代码在进行用户注册的时候加密存储用户密码

user.setPassword(passwordEncoder.encode(user.getPassword()));
//将User保存到数据库表,该表包含password列

对于upgradeEncoding,例如:如果长期使用一个密码不变,可能存在被破解的风险,建议一段时间可以自动提示需要进行密码的升级操作

接口实现类

BCryptPasswordEncoder 是Spring Security推荐使用的PasswordEncoder接口实现类

public class PasswordEncoderTest {
  @Test
  void bCryptPasswordTest(){
    PasswordEncoder passwordEncoder =  new BCryptPasswordEncoder();
    String rawPassword = "123456";  //原始密码
    String encodedPassword = passwordEncoder.encode(rawPassword); //加密后的密码

    System.out.println("原始密码" + rawPassword);
    System.out.println("加密之后的hash密码:" + encodedPassword);

    System.out.println(rawPassword + "是否匹配" + encodedPassword + ":"   //密码校验:true
            + passwordEncoder.matches(rawPassword, encodedPassword));

    System.out.println("654321是否匹配" + encodedPassword + ":"   //定义一个错误的密码进行校验:false
            + passwordEncoder.matches("654321", encodedPassword));
  }
}

上面的测试用例执行的结果是下面这样的。(注意:对于同一个原始密码,每次加密之后的hash密码都是不一样的,这正是BCryptPasswordEncoder的强大之处,它不仅不能被破解,想通过常用密码对照表进行大海捞针你都无从下手

原始密码123456
加密之后的hash密码:$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm
123456是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:true
654321是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:false

BCrypt产生随机盐(盐的作用就是每次做出来的菜味道都不一样)。这一点很重要,因为这意味着每次encode将产生不同的结果。

$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm

BCrypt加密后的密码有三个部分,由 $分隔:

  • "2a"表示 BCrypt 算法版本
  • "10"表示算法的强度
  • "zt6dUMTjNSyzINTGyiAglu"部分实际上是随机生成的盐。通常来说前 22个字符是盐,剩余部分是纯文本的实际哈希值。
BCrypt*算法生成长度为 60 的字符串,因此我们需要确保密码将存储在可以容纳密码的数据库列中。

formLogin模式登录认证

Spring Security的HttpBasic模式,该模式比较简单,只是进行了通过携带Http的Header进行简单的登录验证,而且没有可以定制的登录页面,所以使用场景比较窄。

对于一个完整的应用系统,与登录验证相关的页面都是高度定制化的,非常美观而且提供多种登录方式。这就需要Spring Security支持我们自己定制登录页面,也就是本文给大家介绍的formLogin模式登录认证模式。
需要注意的是:有的朋友会被Form Login这个名字误解,Form Login不是只有使用html中的form 表单才能实现登录功能,使用js发起登录请求也是可以的

准备工作

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

把下面的代码从BizpageController里面删掉。这涉及到一个非常重要的问题,就是Spring Security的登录认证并不需要我们自己去写登录认证的Controller方法,而是使用过滤器UsernamePasswordAuthenticationFilter(下一节会源码分析),这个过滤器是默认集成的,所以并不需要我们自己去实现登录认证逻辑。我们实现登录功能只需要做配置就可以了,所以把下面的代码从项目里面删掉。

// 登录
@PostMapping("/login")
public String index(String username,String password) {
    return "index";
}

说明

formLogin登录认证模式的三要素:

  • 登录认证逻辑-登录URL、如何接收登录参数、登陆成功后逻辑(静态)
  • 资源访问控制规则-决定什么用户、什么角色可以访问什么资源(动态-数据库)
  • 用户具有角色权限-配置某个用户拥有什么角色、拥有什么权限(动态-数据库)

一般来说,使用权限认证框架的的业务系统登录验证逻辑是固定的,而资源访问控制规则和用户信息是从数据库或其他存储介质灵活加载的。但本文所有的用户、资源、权限信息都是代码配置写死的,旨在为大家介绍formLogin认证模式,如何从数据库加载权限认证相关信息我还会结合RBAC权限模型再写文章的

登录认证及资源访问权限的控制

首先,我们要继承WebSecurityConfigurerAdapter ,重写configure(HttpSecurity http) 方法,该方法用来配置登录验证逻辑。请注意看下文代码中的注释信息。

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.csrf().disable() //禁用跨站csrf攻击防御,后面的章节会专门讲解
       .formLogin()
       //登录页面
           .loginPage("/login.html")//一旦用户的请求没有权限就跳转到这个页面
           .loginProcessingUrl("/login")//登录表单form中action的地址,也就是处理认证请求的路径
           .usernameParameter("username")///登录表单form中用户名输入框input的name名,不修改的话默认是username
           .passwordParameter("password")//form中密码输入框input的name名,不修改的话默认是password
           .defaultSuccessUrl("/")//登录认证成功后默认转跳的路径
       .and()
           .authorizeRequests()
           .antMatchers("/login.html","/login").permitAll()//不需要通过登录验证就可以被访问的资源路径
           .antMatchers("/","/biz1","/biz2") //资源路径匹配
               .hasAnyAuthority("ROLE_user","ROLE_admin")  //user角色和admin角色都可以访问
           .antMatchers("/syslog","/sysuser")  //资源路径匹配
               .hasAnyRole("admin")  //admin角色可以访问
           //.antMatchers("/syslog").hasAuthority("sys:log")
           //.antMatchers("/sysuser").hasAuthority("sys:user")
           .anyRequest().authenticated();
}

上面的代码分为两部分:

  • 第一部分是formLogin配置段,用于配置登录验证逻辑相关的信息。如:登录页面、登录成功页面、登录请求处理路径等。和login.html页面的元素配置要一一对应。
  • "/"在spring boot应用里面作为资源访问的时候比较特殊,它就是“/index.html”.所以defaultSuccessUrl登录成功之后就跳转到index.html

  • 第二部分是authorizeRequests配置段,用于配置资源的访问控制规则。如:开发登录页面的permitAll开放访问,“/biz1”(业务一页面资源)需要有角色为user或admin的用户才可以访问。
  • hasAnyAuthority("ROLE_user","ROLE_admin")等价于hasAnyRole("user","admin"),角色是一种特殊的权限
  • "sys:log""sys:user"是我们自定义的权限ID,有这个ID的用户可以访问对应的资源

这时候我们通过浏览器访问,随便测试一个用户没有访问权限的资源,都会跳转到login.html页面。

用户及角色信息配置

在上文中,我们配置了登录验证及资源访问的权限规则,我们还没有具体的用户,下面我们就来配置具体的用户。重写WebSecurityConfigurerAdapterconfigure(AuthenticationManagerBuilder auth)方法

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
                .withUser("user")
                .password(passwordEncoder().encode("123456"))
                .roles("user")
            .and()
                .withUser("admin")
                .password(passwordEncoder().encode("123456"))
                //.authorities("sys:log","sys:user")
                .roles("admin")
            .and()
                .passwordEncoder(passwordEncoder());//配置BCrypt加密
}

@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}
  • inMemoryAuthentication指的是在内存里面存储用户的身份认证和授权信息。
  • withUser(“user”)用户名是user
  • password(passwordEncoder().encode(“123456”))密码是加密之后的123456
  • authorities(“sys:log”,“sys:user”)指的是admin用户拥有资源ID对应的资源访问的的权限:"/syslog"和"/sysuser"
  • roles()方法用于指定用户的角色,一个用户可以有多个角色

静态资源访问

在我们的实际开发中,登录页面login.html和控制层Controller登录验证’/login’都必须无条件的开放。除此之外,一些静态资源如css、js文件通常也都不需要验证权限,我们需要将它们的访问权限也开放出来。下面就是实现的方法:重写WebSecurityConfigurerAdapter类的configure(WebSecurity web) 方法

@Override
    public void configure(WebSecurity web) {
        //将项目中静态资源路径开放出来
        web.ignoring().antMatchers( "/css/**", "/fonts/**", "/img/**", "/js/**");
    }

那么这些静态资源的开放,和Controller服务资源的开放为什么要分开配置?有什么区别呢?

  • Controller服务资源要经过一系列的过滤器的验证,我们配置的是验证的放行规则
  • 这里配置的是静态资源的开放,不经过任何的过滤器链验证,直接访问资源。

源码解析登录验证流程

Spring Security的登录验证流程核心就是过滤器链。

  • 贯穿于整个过滤器链始终有一个上下文对象SecurityContext和一个Authentication对象(登录认证的主体)
  • 一旦某一个该主体通过其中某一个过滤器的认证,Authentication对象信息被填充,比如:isAuthenticated=true表示该主体通过验证。
  • 如果该主体通过了所有的过滤器,仍然没有被认证,在整个过滤器链的最后方有一个FilterSecurityInterceptor过滤器(虽然叫Interceptor,但它是名副其实的过滤器,不是拦截器)。判断Authentication对象的认证状态,如果没有通过认证则抛出异常,通过认证则访问后端API。
  • 之后进入响应阶段,FilterSecurityInterceptor抛出的异常被ExceptionTranslationFilter对异常进行相应的处理。比如:用户名密码登录异常,会被引导到登录页重新登陆。
  • 如果是登陆成功且没有任何异常,在请求响应中最后一个过滤器SecurityContextPersistenceFilter中将SecurityContext放入session。下次再进行请求的时候,直接从SecurityContextPersistenceFilter的session中取出认证信息。从而避免多次重复认证。

SpringSecurity提供了多种登录认证的方式,由多种Filter过滤器来实现,比如:

  • BasicAuthenticationFilter实现的是HttpBasic模式的登录认证
  • UsernamePasswordAuthenticationFilter实现用户名密码的登录认证
  • RememberMeAuthenticationFilter实现登录认证的“记住我”的功能
  • SocialAuthenticationFilter实现社交媒体方式登录认证的处理,如:QQ、微信
  • Oauth2AuthenticationProcessingFilter和Oauth2ClientAuthenticationProcessingFilter实现Oauth2的鉴权方式

根据我们不同的需求实现及配置,不同的Filter会被加载到应用中。

过滤器登录验证细节

构建登录认证主体

如图所示,当用户登陆的时候首先被某一种认证方式的过滤器拦截(以用户名密码登录为例)。如:UsernamePasswordAuthenticationFilter会使用用户名和密码创建一个登录认证凭证:UsernamePasswordAuthenticationToken,进而获取一个Authentication对象,该对象代表身份验证的主体,贯穿于用户认证流程始终。

多种认证方式的管理 ProviderManager

随后使用AuthenticationManager 接口对登录认证主体进行authenticate认证。

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throwsAuthenticationException;
}

ProviderManager继承于AuthenticationManager是登录验证的核心类。

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ……
    private List<AuthenticationProvider> providers;
    ……

ProviderManager保管了多个AuthenticationProvider,每一种登录认证方式都可以尝试对登录认证主体进行认证。只要有一种方式被认证成功,Authentication对象就成为被认可的主体。

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;
    boolean supports(Class<?> var1);
}
  • RememberMeAuthenticationProvider定义了“记住我”功能的登录验证逻辑
  • DaoAuthenticationProvider加载数据库用户信息,进行用户密码的登录验证

数据库加载用户信息 DaoAuthenticationProvider

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

从数据库获取用户信息

所以当我们需要加载用户信息进行登录验证的时候,我们需要实现UserDetailsService接口,重写loadUserByUsername方法,参数是用户输入的用户名。返回值是UserDetails

SecurityContext

完成登录认证之后,将认证完成的Authtication对象(authenticate: true, 有授权列表authority list, 和username信息)放入SecurityContext上下文里面。后续的请求就直接从SecurityContextFilter中获得认证主体,从而访问资源。

结合源码讲解登录验证流程

我们就以用户名、密码登录方式为例讲解一下Spring Security的登录认证流程。

UsernamePasswordAuthenticationFilter

该过滤器封装用户基本信息(用户名、密码),定义登录表单数据接收相关的信息。如:

  • 默认的表单用户名密码input框name是username、password
  • 默认的处理登录请求路径是/login、使用POST方法

AbstractAuthenticationProcessingFilter的doFilter方法的验证过程

UsernamePasswordAuthenticationFilter继承自抽象类AbstractAuthenticationProcessingFilter,该抽象类定义了验证成功与验证失败的处理方法。

验证成功之后的Handler和验证失败之后的handler

AbstractAuthenticationProcessingFilter中定义了验证成功与验证失败的处理Handler。

private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

也就是说当我们需要自定义验证成功或失败的处理方法时,要去实现AuthenticationSuccessHandler or AuthenticationfailureHandler接口

需要自定义登录结果的场景

http.formLogin()
        .loginPage("/login.html")//一旦用户的请求没有权限就跳转到这个页面
        .defaultSuccessUrl("/")//登录认证成功后默认转跳的路径
        .failureUrl("/login.html")//登陆失败跳转路径
  • 当我们登录成功的时候,是由AuthenticationSuccessHandler进行登录结果处理,默认跳转到defaultSuccessUrl配置的路径对应的资源页面(一般是首页index.html)。
  • 当我们登录失败的时候,是由AuthenticationfailureHandler进行登录结果处理,默认跳转到failureUrl配置的路径对应的资源页面(一般也是跳转登录页login.html,重新登录)。

但是在web应用开发过程中需求是千变万化的,有时需要我们针对登录结果做个性化处理,比如:

  • 我们希望不同的人登陆之后,看到不同的首页(及向不同的路径跳转)
  • 我们应用是前后端分离的,验证响应结果是JSON格式数据,而不是页面跳转
  • …… 其他未尽的例子

自定义登陆成功的结果处理

AuthenticationSuccessHandler接口是Security提供的认证成功处理器接口,我们只需要去实现它即可。但是通常来说,我们不会直接去实现AuthenticationSuccessHandler接口,而是继承SavedRequestAwareAuthenticationSuccessHandler 类,这个类会记住用户上一次请求(上一次登录成功后请求跳转的路径)的资源路径,比如:用户请求books.html,没有登陆所以被拦截到了登录页,当你完成登陆之后会自动跳转到books.html,而不是主页面。

@Component
public class MyAuthenticationSuccessHandler 
                        extends SavedRequestAwareAuthenticationSuccessHandler {

    //在application配置文件中配置登陆的类型是JSON数据响应还是做页面响应
    @Value("${spring.security.logintype}")
    private String loginType;
    //Jackson JSON数据处理类
    private  static ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, 
                                        HttpServletResponse response, 
                                        Authentication authentication) 
                                        throws ServletException, IOException {

        if (loginType.equalsIgnoreCase("JSON")) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(AjaxResponse.success()));
        } else {
            // 会帮我们跳转到上一次请求的页面上
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}
  • 在上面的自定义登陆成功处理中,既适应JSON前后端分离的应用登录结果处理,也适用于模板页面跳转应用的登录结果处理
  • ObjectMapper 是Spring Boot默认集成的JSON数据处理类库Jackson中的类。
  • AjaxResponse是一个自定义的通用的JSON数据接口响应类。

自定义登录失败的结果处理

这里我们同样没有直接实现AuthenticationFailureHandler接口,而是继承SimpleUrlAuthenticationFailureHandler 类。该类中默认实现了登录验证失败的跳转逻辑,即登陆失败之后回到登录页面。我们可以利用这一点简化我们的代码。

@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    //在application配置文件中配置登陆的类型是JSON数据响应还是做页面响应
    @Value("${spring.security.logintype}")
    private String loginType;

    private  static ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response, 
                                        AuthenticationException exception) 
                                        throws IOException, ServletException {

        if (loginType.equalsIgnoreCase("JSON")) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(
                    objectMapper.writeValueAsString(
                            AjaxResponse.error(
                                    new CustomException(
                                        CustomExceptionType.USER_INPUT_ERROR,
                                        "用户名或密码存在错误,请检查后再次登录"))));
        } else {
            response.setContentType("text/html;charset=UTF-8");
            //默认是重定向到失败页面
            super.onAuthenticationFailure(request, response, exception);
        }

    }
}
  • 在上面的自定义登陆失败处理中,既适应JSON前后端分离的应用登录失败结果处理,也适用于模板页面跳转应用的登录失败结果处理
  • 登陆失败之后,将默认跳转到默认的failureUrl,即登录界面。

配置SecurityConfig

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Resource
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

   @Override
   protected void configure(HttpSecurity http) throws Exception {
       http.csrf().disable() //禁用跨站csrf攻击防御,后面的章节会专门讲解
           .formLogin()
           .successHandler(myAuthenticationSuccessHandler)
           .failureHandler(myAuthenticationFailureHandler)
           //.defaultSuccessUrl("/index")//登录认证成功后默认转跳的路径
           //.failureUrl("/login.html") //登录认证是被跳转页面
}
  • 将自定义的AuthenticationSuccessHandlerAuthenticationFailureHandler注入到Spring Security配置类中
  • 使用fromlogin模式,配置successHandler和failureHandler。
  • 不要配置defaultSuccessUrl和failureUrl,否则自定义handler将失效。handler配置与URL配置只能二选一

默认的处理登录请求的url,登录失败的跳转url,退出登录的url

protected final void updateAuthenticationDefaults() {
        if (this.loginProcessingUrl == null) {
        //处理登录
            this.loginProcessingUrl(this.loginPage);
        }

        if (this.failureHandler == null) {
        //登录失败
            this.failureUrl(this.loginPage + "?error");
        }

        LogoutConfigurer<B> logoutConfigurer = (LogoutConfigurer)((HttpSecurityBuilder)this.getBuilder()).getConfigurer(LogoutConfigurer.class);
        if (logoutConfigurer != null && !logoutConfigurer.isCustomLogoutSuccess()) {
        //退出登录
            logoutConfigurer.logoutSuccessUrl(this.loginPage + "?logout");
        }

    }

JSON登录方式的测试

最后可以用下面的代码测试一下登录验证的结果。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
    <script src="https://cdn.staticfile.org/jquery/1.12.3/jquery.min.js"></script>
</head>
<body>
<h1>字母哥业务系统登录</h1>
<form action="/login" method="post">
    <span>用户名称</span><input type="text" name="uname" id="username"/> <br>
    <span>用户密码</span><input type="password" name="pword"  id="password"/> <br>
    <input type="button" onclick="login()" value="登陆">
</form>

<script type="text/javascript">
    function login() {
        var username = $("#username").val();
        var password = $("#password").val();
        if (username === "" || password === "") {
            alert('用户名或密码不能为空');
            return;
        }

        $.ajax({
            type: "POST",
            url: "/login",
            data: {
                "uname": username,   //这里的参数名称要和Spring Security配置一致
                "pword": password
            },
            success: function (json) {
                if(json.isok){
                    location.href = '/'; //index.html
                }else{
                    alert(json.message);
                    location.href = '/login.html'; //index.html
                }
            },
            error: function (e) {

            }
        });
    }
</script>

</body>
</html>

自定义权限访问异常结果处理

除了登陆成功、登陆失败的结果处理,Spring Security还为我们提供了其他的结果处理类。比如用户未登录就访问系统资源,可以实现AuthenticationEntryPoint 接口进行响应处理,提示用户应该先去登录

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, 
                        HttpServletResponse httpServletResponse, 
                        AuthenticationException e) throws IOException, ServletException{
         //仿造上文使用response将响应信息写回
    }
}

比如用户登录后访问没有权限访问的资源,可以实现AccessDeniedHandler 接口进行相应处理,提示用户没有访问权限

public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, 
                       HttpServletResponse httpServletResponse, 
                        AccessDeniedException e) throws IOException, ServletException {
        //仿造上文使用response将响应信息写回
    }
}

通过下面的方法进行注册生效

protected void configure(HttpSecurity http) throws Exception {
    //省略其他配置内容
    http.exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler)
            .authenticationEntryPoint(authenticationEntryPoint);
     //省略其他配置内容
}

详解

AuthenticationEntryPoint 用来解决匿名用户访问无权限资源时的异常

AccessDeineHandler 用来解决认证过的用户访问无权限资源时的异常

AuthenticationEntryPoint

AuthenticationEntryPoint 是 Spring Security Web 一个概念模型接口,顾名思义,他所建模的概念是:“认证入口点”。

它在用户请求处理过程中遇到认证异常时,被 ExceptionTranslationFilter 用于开启特定认证方案 (authentication schema) 的认证流程。

该接口只定义了一个方法 :

void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException;

这里参数 request 是遇到了认证异常 authException 用户请求,response 是将要返回给客户的相应,方法 commence 实现,也就是相应的认证方案逻辑会修改 response 并返回给用户引导用户进入认证流程。

当用户请求了一个受保护的资源,但是用户没有通过认证,那么抛出异常,AuthenticationEntryPoint.Commence(…)就会被调用。这个对应的代码在ExceptionTranslationFilter中,如下,当ExceptionTranslationFilter catch到异常后,就会间接调用AuthenticationEntryPoint。

public class ExceptionTranslationFilter extends GenericFilterBean {
    private AuthenticationEntryPoint authenticationEntryPoint;
......
 
    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);
            this.logger.debug("Chain processed normally");
        } catch (IOException var9) {
            throw var9;
        } catch (Exception var10) {
            ......
 
            this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase);
        }
 
    }
 
    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
        ......
        this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
        ......
    }
 
    protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
        SecurityContextHolder.getContext().setAuthentication((Authentication)null);
        this.requestCache.saveRequest(request, response);
        this.logger.debug("Calling Authentication entry point.");
        this.authenticationEntryPoint.commence(request, response, reason);
    }
......

匿名用户访问某个接口时

/** * 认证失败处理类 返回未授权 * 用来解决匿名用户访问无权限资源时的异常 */
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
            throws IOException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("text/javascript;charset=utf-8");
        response.getWriter().print(JSONObject.toJSONString(RestMsg.error("没有访问权限!")));
    }
}

AccessDeniedHandler

AccessDeniedHandler 仅适用于已通过身份验证的用户。未经身份验证的用户的默认行为是重定向到登录页面(或适用于正在使用的身份验证机制的任何内容)。

已经授权但是没有访问权限

/** * 认证失败处理类 返回未授权 * 用来解决认证过的用户访问无权限资源时的异常 */
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("text/javascript;charset=utf-8");
        response.getWriter().print(JSONObject.toJSONString(RestMsg.error("没有访问权限!")));
    }
}

配置

public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Autowired
    private CustomAuthenticationEntryPoint authenticationEntryPoint;
    
    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;
   
    // 省略部分代码
 
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
         httpSecurity
                // 认证失败处理类
                .exceptionHandling()
                    .authenticationEntryPoint(authenticationEntryPoint)
                    .accessDeniedHandler(customAccessDeniedHandler);               
    }
 
    // 省略部分代码
 
}

Spring Security创建使用session的方法

Spring Security提供4种方式精确的控制会话的创建:

  • always:如果当前请求没有对应的session存在,Spring Security创建一个session。
  • ifRequired(默认): Spring Security在需要使用到session时才创建session
  • never: Spring Security将永远不会主动创建session,但是如果session在当前应用中已经存在,它将使用该session
  • stateless:Spring Security不会创建或使用任何session。适合于接口型的无状态应用(前后端分离无状态应用),这种方式节省内存资源

在Spring Security配置中加入session创建的策略。继承WebSecurityConfigurerAdapter ,重写configure(HttpSecurity http) 方法

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement()
        .sessionCreationPolicy(
                SessionCreationPolicy.IF_REQUIRED
        )
}

重要的是:该配置只能控制Spring Security如何创建与使用session,而不是控制整个应用程序。如果我们不明确指定,Spring Security可能不会创建session,但是我们的应用程序可能会创建session(一般spring应用的session管理交由Spring Session进行)!

会话超时管理

session会话超时时间配置

在Spring boot应用中有两种设置会话超时时间的方式,Spring Security对这两种方式完全兼容,即:当会话超时之后用户需要重新登录才能访问应用:

  • server.servlet.session.timeout=15m
  • spring.session.timeout = 15m

第一种方式是springBoot应用自带的session超时配置,第二种方式是我们使用Spring Session之后,提供的session超时配置。第二种方式的优先级更高。

注意:在Spring Boot中Session超时最短的时间是一分钟,当你的设置小于一分钟的时候,默认为一分钟。

会话超时处理

会话超时之后,我们通常希望应用跳转到一个指定的URL,显示会话超时信息。可以使用如下的配置的代码实现。

http.sessionManagement()
          .invalidSessionUrl("/invalidSession.html");    //非法超时session跳转页面

以上路径需要配置permitAll()权限,即无需授权即可访问。

Spring Security的会话固化保护

session-fixation-protection 即session的固化保护功能,该功能的目的是一定程度上防止非法用户窃取用户session及cookies信息,进而模拟session的行为。

//默认配置
  http.sessionManagement().sessionFixation().migrateSession()

如果这不是您需要的方式,则可以使用其他的选项:

  • 设置为“none”时,原始会话不会无效
  • 设置“newSession”后,将创建一个干净的会话,而不会复制旧会话中的任何属性
  • migrateSession - 即对于同一个cookies的SESSIONID用户,每次登录访问之后访问将创建一个新的HTTP Session会话,旧的HTTP Session会话将无效,并且旧Session会话的属性将被复制。在Servlet 3.0及其之前的版本,这种方式是默认的
  • changeSessionId - 这种方式不会创建新的session,作为替代,使用Servlet 容器(HttpServletRequest#changeSessionId())提供的会话固化保护功能 。这个选项在Servlet 3.1 (Java EE 7) 或者更新版本的web容器下默认生效。 每次登录访问之后都更换sessionid,但是没有新建session会话。

Cookie的安全

熟悉Session实现原理的朋友一定都知道,提高Cookies的安全性,实际上就是提高session的安全性。在Spring Boot中可以通过配置方式来实现:

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
  • httpOnly:如果为true,则浏览器脚本将无法访问cookie
  • secure:如果为true,则仅通过HTTPS连接发送cookie,HTTP无法携带cookie。

同账号多端登录踢下线

虽然固化保护的策略可以一定程度保护session复制、窃取,但是在我们绝大部分的应用需求中,都会限制一个用户只能占用一个session。就像我们经常使用QQ,用户在别的地方登录,之前的登陆就会下线。使用Spring Security的配置我们可以轻松的实现这个功能。

.sessionManagement()
    .maximumSessions(1)
    .maxSessionsPreventsLogin(false)
    .expiredSessionStrategy(new CustomExpiredSessionStrategy())
  • maximumSessions表示同一个用户(session)最大的登录数量
  • maxSessionsPreventsLogin提供两种session保护策略:
  • true表示已经登录就不予许再次登录,
  • false表示允许再次登录但是之前的登录账户会被踢下线
  • expiredSessionStrategy表示自定义一个session被下线(超时)之后的处理策略。

跳转到指定页面

通过实现SessionInformationExpiredStrategy 接口来自定义session被下线(超时)之后的处理策略。可以跳转到某一个url对应的HTML页面上,这个页面给用户相对有好的提示:您的登录认证已经过期,或者您在另外的设备上进行登录,这里被迫下线。

public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy {

    //页面跳转的处理逻辑
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
        // 是跳转html页面,url代表跳转的地址
        redirectStrategy.sendRedirect(event.getRequest(), event.getResponse(), "某个url");
    }
}

Json的友好数据提示

如果你开发的是前后端分离的应用,使用JSON进行数据交互,可以使用如下代码。

public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy {
    
    //jackson的JSON处理对象
    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
        Map<String, Object> map = new HashMap<>();
        map.put("code", 403);
        map.put("msg", "您的登录已经超时或者已经在另一台机器登录,您被迫下线。" 
                                + event.getSessionInformation().getLastRequest());
        
        // Map -> Json
        String json = objectMapper.writeValueAsString(map);

        //输出JSON信息的数据
        event.getResponse().setContentType("application/json;charset=UTF-8");
        event.getResponse().getWriter().write(json);
    }
}

测试方法:

  • 设置maxSessionsPreventsLogin为false,打开两个浏览器、一个先登录、一个后登录。 然后随便访问一个先登录的浏览器中的应用页面,比如:“用户管理”,显示:“您的登录已经超时或者已经在另一台机器登录,您被迫下线”。表示我们的配置正确。
  • 然后关闭浏览器再次测试,设置maxSessionsPreventsLogin为true,重启应用。先在一个浏览器登录的用户可以登陆,但是另一个浏览器再次登陆就无法登陆,会被拒绝。

cookie和session复习

Session(超详细)
做web开发,怎么能不懂cookie、session和token呢?

相关文章