SpringSecurity - 基于Session实现登陆认证

  • 1 SpringSecurity介绍
  • 2 依赖
  • 3 代码示例
  • 3.1 代码说明


1 SpringSecurity介绍

SpringSecurity是一种用于身份验证和访问控制的强大且高度可定制化的框架,它是Spring应用程序中,要实现程序级别安全性的主要选择,SpringSecurity提供了一种高度定制的身份验证,授权,防止常见攻击(固定会话攻击,XSS脚本攻击,CSRF攻击等)的方法。

如果你现在正在使用Spring框架进行Web开发,并且有程序安全性的需求,那么使用SpringSecurity将是一个不错的选择。

《SpringSecurity in Action》那本书中提到,学习SpringSecurity确实有不小的成本。其中一个成本就是它很容易给学习者带来一些困扰,因为它是可高度定制的,所以我们在不同的网站,或是不同的书籍中,会看到对同一功能有不同配置方式,以致于我们不知从何选择。其实只要知道其中一种就OKAY了。还有一个学习成本就是SpringSecurity里的概念实在是太多了,实在是太多了。比如初学时接触的Authentication这个对象,就很够初学者喝几壶的了。不过没关系,只要我们抱着不去精通SpringSecurity的心态,就会慢慢地放下焦虑,日拱一卒式的掌握SpringSecurity。

本文是基于Session实现登陆认证,所以有必要先说一下这个基于Session的原理。SpringSecurity的核心功能,就是通过一系列的过滤器来实现安全性的。其中有一个过滤器叫SecurityContextPersistenceFilter,在这个过滤器里,有两行关键代码SecurityContext contextBeforeChainExecution = repo.loadContext(holder);repo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse());repo是一个SecurityContextRepository对象,其中一个实现类就是HttpSessionSecurityContextRepository。看名子就很容易理解,这是一个基于HttpSession的SecurityContext的仓库(一不小心就又出现了一个SpringSecurity时的另一个概念–SecurityContext)。其实过程就是登陆请求中,把用户的登陆成功后的信息保存在HttpSession中,在其它请求中,从HttpSession中获取登陆成功后的信息。HttpSessionSecurityContextRepository就是存储这些HttpSession的地方,类似于是一个数据库。

基于Session实现登陆认证,是SpringSecurity默认的登陆认证方式,不需要我们做额外的配置。

2 依赖

<dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <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>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.20</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.75</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

3 代码示例

代码中使用到了一些工具类,如CommonResult,该工具类并没有在代码中,这里只针对SpringSecurity的核心内容进行了说明。如果需要完成的代码示例,可以到gitee上下面完成代码 hgd11-security security_session分支。

package cn.hgd11.security.config;

import cn.hgd11.security.common.CommonResult;
import cn.hgd11.security.enums.GlobalCodeEnum;
import cn.hgd11.security.util.StringUtils;
import cn.hutool.extra.servlet.ServletUtil;
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

/**
 * @author wangkaige
 */
@EnableWebSecurity(debug = true)
public class Hgd11SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /*
         * 在这里配置基于内存的用户,并且指定了不使用加密的PasswordEncoder,学习阶段完全没有这个必要了。
         */
        InMemoryUserDetailsManager inMemoryUserDetails = new InMemoryUserDetailsManager();
        // 向内存中注册两个用户
        inMemoryUserDetails.createUser(User.withUsername("testUser").password("123").authorities("read").build());
        inMemoryUserDetails.createUser(User.withUsername("testUser02").password("123").authorities("read").build());
        auth.userDetailsService(inMemoryUserDetails)
            .passwordEncoder(passwordEncoder());

        // 通过匿名实现类定义一个验证码登陆的Provider
        DaoAuthenticationProvider varificationCodeProvider = new DaoAuthenticationProvider() {
            @Override
            protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
                /**
                 * 在这里想模拟一下验证码登陆方式
                 * {@link ProviderManager#authenticate(Authentication)}类里说的好,【If more than one
                 * AuthenticationProvider supports the passed Authentication object, the first
                 * one able to successfully authenticate the Authentication object determines the result,】
                 * 当有多个 AuthenticationProvider 时,第一个成功认证的 AuthenticationProvider 将决定最后的结果。用中国话就是,谁先认证成功了,谁说了算,认证过程到此结束。
                 * 所以我们在这里加一个 AuthenticationProvider 用于验证码登陆的认证。如果使用验证码的话, DaoAuthenticationProvider 认证肯定是失败的,所以会走到这里来。
                 *
                 * DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法做了两个事情 一是校验了密码是否为null,二是校验了密码是否正确
                 * 这里想模拟的是验证码登陆,所以照猫画虎,也做两步校验就OKAY了,一是校验验证码是否为null,二是校验难证码是否正确。
                 */
                if (authentication.getCredentials() == null) {
                    logger.debug("Authentication failed: no credentials provided");

                    throw new BadCredentialsException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.badCredentials",
                        "Bad credentials"));
                }

                /*
                 * authentication 里的 credentials 是在 UsernamePasswordAuthenticationFilter里获取的
                 * 		String username = obtainUsername(request);
                 *      String password = obtainPassword(request);
                 * 所以这里要求前端在用验证码请求时,那个验证码的参数名也得是'password',当前username的参数值应该是一个手机号什么的。
                 * 如果想自定义验证码的参数名,那就不能使用这种方案了。
                 * 不过想一下,没必要为了被一些人吹捧的什么程序员强迫症,就非要自定义参数,用这种方案最贴近SpringSecurity,我们为什么不采用呢。
                 */
                String verificationCode = authentication.getCredentials().toString();
                /*
                 * 下一步就是验证 verificationCode 的正确性,这里大多都是在获取验证码请求中,把验证码存到了redis中,这里就假装从redis里获取一下,然后校验成功就行了。
                 */
                // 从redis获取当前用户的验证码
                // 假装认证成功,然后什么也不做就行了,如果认证失败,这里是用抛出异常的形式体现的,什么也不发生表示没有坏事儿发生。
            }
        };
        varificationCodeProvider.setUserDetailsService(inMemoryUserDetails);
        auth.authenticationProvider(varificationCodeProvider);

    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /*
         * 对于SpringSecurity来说,在不同的示例中,对同一功能可能会看到各种各样的配置。
         * 这是因为SpringSecurity对于同一个功能,给出了非常灵活的配置方式。但这种灵活性却给我们带来了其些困扰,让我们觉得学习起来非常的凌乱。
         * 其实这并不应该归罪于SpringSecurity,我们只要在学习过程中掌握其中的一种即可。
         */
        http
            /*
             * 先禁用csrf防护
             */
            .csrf().disable()
            /*
             * form表单方式登陆
             * 该配置会开放一个 form-data 的端点(SpringSecurity里叫EntryPoint,翻译成端点),用于登陆请求,用户名参数为-username   密码参数为-password
             * 在非前后端分离的场景中,通常还会配置一个 loginPage() 和 loginProcessingUrl()
             * 在这个场景下就不需要了,除非我们想自定义登陆端点的地址,可以配置为 loginProcessingUrl("/custom/login")
             * loginPage() - 用来获取登陆页面,就是那个填写用户名密码的页面,在前后端分离的场景中当然就不需要这个配置
             * loginProcessingUrl() - 用来配置登陆请求,就是所谓的真正的那个登陆请求,为POST请求。就是填写完用户名密码后,要把用户名密码提交到的端口
             *
             * 相对于formLogin,SpringSecurity还有一个 httpBasic()的登陆方式,也是SpringSecurity默认的登陆方式。它与 formLogin的不同在于。。。 。。。
             * httpBasic其它算不上是一个要登陆认证后才能去请示资料的一种方式,httpBasic是要求在第个请示中,都传入用户名与密码,在每个请示中都检验用户名与密码,
             * 也就是说,这种方式并不要求一定要有个登陆的环境,只要在请示资源时,如请示用户列表这个接口,在请示中加入用户名与密码就可以进行请示操作了。
             * 这样很明显不太安全,在每个请示中都传送用户名与密码的方式,密码漏掉的可能性就会变大。
             * httpBasic请示的示例像这样: curl -X GET -u user:password http://localhost:8080/user/list
             * 或者是 curl -X GET -H 'Authorization: {user:password的base64编码}' http://localhost:8080/user/list
             */
            .formLogin()
            /*
             * 登陆成功后的处理器
             * 当登录成功后,会进入到这里进行处理后续的事宜
             * 在前后端分离的场景中,这里通常会返回一个告知登陆成功的ajax响应,也可以带上token返回。到底带不带token视实际场景而定。
             * SpringSecurity还提供了一个 successForwardUrl() 配置,参数接口一个url,用于配置登陆成功后要跳转的url地址
             * 在前后端分离的场景,登陆成功后要跳转到哪里去,应该由前端页面自己决定。后端只需要告知登陆是成功还是失败即可。
             */
            .successHandler((request, response, authentication) -> ServletUtil.write(response, JSONObject.toJSONString(CommonResult.success("登陆成功")), MediaType.APPLICATION_JSON_UTF8_VALUE))
            /*
             * 登录失败的处理器,同successHandler,这里返回一个告知登陆失败的ajax响应
             * 不同的是,这个处理器的入参里,不是一个Authentication对象,而是一个AuthenticationException,我们可以根据不同的异常类型,返回不同的响应信息
             * 类似于
             * if (exception instanceof PasswordException) {
             *   // 返回密码错误
             *   // 但是不建议明确地告知是密码错误,因为这样用户让骇客们知道,使用的用户名是正常的,只是密码有误而已。
             *   // 所以我们通常都会返回【用户名或密码错误】
             * } else {
             *   // 其它登陆异常信息
             * }
             *
             */
            .failureHandler((request, response, exception) -> ServletUtil.write(response, JSONObject.toJSONString(CommonResult.error(GlobalCodeEnum.UNAUTHORIZED.getCode(), "登陆异常")), MediaType.APPLICATION_JSON_UTF8_VALUE))
            .and()
            .logout()
            .logoutSuccessHandler((request, response, authentication) -> ServletUtil.write(response, JSONObject.toJSONString(CommonResult.error(GlobalCodeEnum.GL_SUCC_200.getCode(), "登出成功")), MediaType.APPLICATION_JSON_UTF8_VALUE))
            .and()
            /*
             * 认证失败的处理器,区别与 failureHandler
             * failureHandler - 在登陆请求中,由于密码错误,或是用户名错误等一些异常,造成的登陆失败时的处理器
             * exceptionHandling - 是在正常请求中,比如用户请求 /user/list 接口,发现token超时了,或是其它的认证失败的情况,会进入到这里进行处理
             *
             * 同样都是认证失败的处理方式,为什么要有两个配置呢?其它这个问题应该说为什么不能有两个配置呢?这里最终都是通过 AuthenticationEntryPoint 的实现类进行处理的,我们完全可以
             * 用同一个实现类配置在 failureHandler 与 exceptionHandling 里面,但 SpringSecurity 给了我们可以分别配置的可能,可以认为这是一种灵活性的体现。
             *
             * 也可以在 exceptionHandling 中传入一个入参。配置 authenticationEntryPoint 认证失败的 authenticationEntryPoint,效果是一样的
             * exceptionHandling(
             *  httpSecurityExceptionHandlingConfigurer ->
             *               httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(
             *                       (request, response, authException) ->
             *                               ServletUtil.write(response, JSONObject.toJSONString(CommonResult.error(GlobalCodeEnum.UNAUTHORIZED.getCode(), "登陆超时或未登陆")), MediaType.APPLICATION_JSON_UTF8_VALUE))
             * )
             */
            .exceptionHandling()
            .authenticationEntryPoint((request, response, authException) -> ServletUtil.write(response, JSONObject.toJSONString(CommonResult.error(GlobalCodeEnum.UNAUTHORIZED.getCode(), "登陆超时或未登陆")), MediaType.APPLICATION_JSON_UTF8_VALUE))
            .and()
            /*
             * 配置认证规则
             * 这个是一层一层进行配置的,通过 .and() 进行连接
             * 最先配置的优先级最高,比如下面的配置,在最后的时候配置了所有的请求都需要进行认证才能访问
             * 前面配置了 test/anonymous 可以匿名访问,那么 hgd11-security-api/test/anonymous可以匿名访问
             * 这条规则会生效
             *
             * 这里需要注意的是,如果在配置文件中配置了 server.servlet.context-path ,这里配置路径规则时,不需要加 server.servlet.context-path 里
             * 配置的请求前缀,SpringSecurity在进行路径匹配时,对比的是uri。可能在SpringMVC里,uri是指去掉server.servlet.context-path的url,这里就不是
             * 很清楚了。
             *
             * 可以匿名访问的路径与permitAll的请示路径,在结果上有相同的效果,到底SpringSecurity对此有什么高级的应用,这个还不得而知。如果只是想放行一些请示路径
             * 的话,这里使用哪种方式其它都是可以的了。
             */
            // 1 认证规则配置
            .authorizeRequests()
            // 2 表示 swagger-ui.html这个请求
            .mvcMatchers("test/swagger-ui.html")
            // 3 允许所有任何用户以任何方式进行请求
            .permitAll()
            .and()
            // 1 认证规则配置
            .authorizeRequests()
            // 2 表示 test/anonymous 这个请求
            .mvcMatchers("test/anonymous")
            // 3 允许匿名请问
            .anonymous()
            .and()
            // 1 认证规则配置
            .authorizeRequests()
            // 2 表示所有请求
            .anyRequest()
            // 3 都需要认证才能请求
            .authenticated();
    }
}

3.1 代码说明

protected void configure(AuthenticationManagerBuilder auth) throws Exception用于配置AuthenticationManager这是在认证过程中一个非常重要的组件,整个认证过程,如获取正在登陆的用户信息,验证用户密码的操作都是在AuthenticationManager里完成的。AuthenticationManager一个最常见的实现类是ProviderManager,我们可以实现自己的AuthenticationProvider,然后注册到ProviderManager里,从而实现自定义的认证逻辑。
本代码示例中使用的是基于内存的UserDetailsServiceInMemoryUserDetailsManager,测试学习当然是可以使用这个实现类的。
本代码示例中还有一个匿名的AuthenticationProvider实现类,这个实现类是从DaoAuthenticationProvider继承过去的。这个实现类模拟了如何基于手机验证码进行登陆认证。