1 Star 3 Fork 0

maqb/spring-security-sample

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
该仓库未声明开源许可证文件(LICENSE),使用请关注具体项目描述及其代码上游依赖。
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README

跟着 https://github.com/lenve/spring-security-samples 学习记录

Demo 内容
form-login SpringSecurity初体验
form-login-2 表单登录-前后端不分离
form-login-3 前后端分离配置
form-login-4 基于内存的用户配置
form-login-5 将用户存到数据库
withjpa 使用jpa操作数据库
rememberme 自动登录
rememberme-persistent 自动登录-降低安全风险
verifycode-0 自定义过滤器-添加验证码功能
verifycode-1 自定义AuthenticationProvider-添加验证码功能
authentication-details 获取登录用户ip,自定义details
session-1 用户并发登录限制--基于内存用户
session-2 用户并发登录限制--基于数据库用户
stricthttpfirewall SpringSecurity自带安全防护
session-3 防御固定会话攻击
session-4 集群化部署-SpringSession进行Session共享
csrf-1
csrf-2 csrf防御
csrf-3
encrypt 密码加密

初步使用SpringSecurity

新建项目

首先创建一个SpringBoot项目,引入Security和Web的依赖


<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>

创建一个接口

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

接下来直接启动项目,访问 http://localhost:8080/hello

发现并没有直接访问到接口,而是自动重定向到了一个登录页面 img.png

默认的登录用户名是user,登录密码在启动时打印在了控制台,当然这个密码每次重新启动都会变化。 具体这个密码是如何生成的,可以查看UserDetailsServiceAutoConfiguration#getOrDeducePassword

Using generated security password: 40fb25e8-e147-40aa-8f77-f6eea3723e11

当登录成功之后,就可以成功的访问到/hello接口了。在SpringSecurity中,默认的登录页面和登录请求都是/login,只不过一个是登录页面(get),一个是登录接口(post)。

非 主流方式配置用户名/密码

1.配置文件

查看配置类,使用自定义的配置去覆盖默认配置


@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
}
spring:
  security:
    user:
      name: maqb
      password: 123

2.配置类

配置类的配置会覆盖yml中的配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder(){
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth)throws Exception{
        auth.inMemoryAuthentication()
                .withUser("maqb")
                .password("456").roles("admin");
    }
}
  1. 首先自定义一个配置类SecurityConfig继承自WebSecurityConfigurerAdapter
  2. 定义一个PasswordEncoder,目前由于不对密码加密,使用NoOpPasswordEncoder
  3. configure方法中,通过inMemoryAuthentication()在内存中配置用户。如果要配置多个用户,使用and()连接

自定义登录页

首先的话在资源目录static下新建login.html,外加一些html、css、images。 然后在SecurityConfig配置类中自定义登录页

    @Override
	public void configure(WebSecurity web)throws Exception{
        web.ignoring().antMatchers("/js/**","/css/**","/images/**");
    }

    @Override
    protected void configure(HttpSecurity http)throws Exception{
        http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin().loginPage("/login.html").permitAll(); // 注意此处要放行login.html请求,否则会一直重定向
    }

前后端不分离配置登录页

form-login2

自定义登录页和登录接口

在SpringSecurity中,我们不做任何配置时,默认的登录页面和登录接口都是/login,也就是说默认会有两个请求,而且是放行的

  • GET localhost:8080/login 登录页
  • POST localhost:8080/login 登录请求

在上一节中简单的自定义了登录页,配置如下:

.and().formLogin().loginPage("/login.html").permitAll(); 

我们自定义了loginPage为/login.html, 这个配置相当于重写了默认的/login, 实际上有两个效果,即配置了登录页,又配置了登录请求

  • GET localhost:8080/login.html 登录页
  • POST localhost:8080/login.html 登录请求

这样的话登录请求是 POST /login.html,看起来很奇怪 我们也可以将登录请求再重新设置,通过loginProcessingUrl()来自定义登录请求

   .and()
        .formLogin()
        .loginPage("/login.html")
        .loginProcessingUrl("/doLogin")
        .permitAll(); // 注意此处要放行login.html请求,否则会一直重定向

这样配置之后,登录页和登录请求就分开了

  • GET localhost:8080/login.html 登录页
  • POST localhost:8080/doLogin 登录请求

有一点要注意,重写了登录请求地址之后,要将请求页面的表单也进行修改 其中的action属性要修改为我们自定义的登录请求


<form action="/doLogin" method="post">
    // ...略
</form>

自定义登录参数

登录参数在默认情况下是username和password


<form action="/doLogin" method="post">
    <input type="text" name="username" id="name">
    <input type="password" name="password" id="pass">
    <button type="submit">
        <span>登录</span>
    </button>
</form>

当然我们也可以自定义这两个参数

                .and()
        .formLogin()
        .loginPage("/login.html")
        .loginProcessingUrl("/doLogin")
        .usernameParameter("name")
        .passwordParameter("passwd")
        .permitAll(); // 注意此处要放行login.html请求,否则会一直重定向

在自定义username和password参数之后,记得好修改html页面表单的对应参数


<form action="/doLogin" method="post">
    <input type="text" name="name" id="name">
    <input type="password" name="passwd" id="pass">
    <button type="submit">
        <span>登录</span>
    </button>
</form>

登录接口/页面/参数 源码查看

登录接口/页面

登录接口和页面的配置都是在 formLogin()之后的

                .and()
        .formLogin()
        .loginPage("/login.html")
        .loginProcessingUrl("/doLog

关于formLogin()之后的配置, 都在类FormLoginConfigurer中, FormLoginConfigure继承抽象类AbstractAuthenticationFilterConfigurer, 在AbstractAuthenticationFilterConfigurer的构造方法中, 默认定以了loginPage

    protected AbstractAuthenticationFilterConfigurer(){
        this.defaultSuccessHandler=new SavedRequestAwareAuthenticationSuccessHandler();
        this.successHandler=this.defaultSuccessHandler;
        this.setLoginPage("/login");
    }

另一方面, 在FormLoginConfigure的init方法中, 调用了父类AbstractAuthenticationFilterConfigurer的init方法

    public void init(H http)throws Exception{
        super.init(http);
        this.initDefaultLoginFilter(http);
    }
    // 父类init
	public void init(B http)throws Exception{
        this.updateAuthenticationDefaults();
        this.updateAccessDefaults(http);
        this.registerDefaultAuthenticationEntryPoint(http);
    }

在 updateAuthenticationDefaults() 方法中, 定义了默认的 loginProcessingUrl 为 loginPage

    protected final void updateAuthenticationDefaults(){
        if(this.loginProcessingUrl==null){
        this.loginProcessingUrl(this.loginPage);
        }
        // 略..
    }

登录参数

登录参数的配置也是在formLogin()之后, 所以也是去FormConfigure这个类中查看

                .and()
        .formLogin()
        .loginPage("/login.html")
        .loginProcessingUrl("/doLogin")
        .usernameParameter("name")
        .passwordParameter("passwd")

查看FormConfigure的构造方法, 可见默认设置的username和password两个参数

    public FormLoginConfigurer(){
        super(new UsernamePasswordAuthenticationFilter(),(String)null);
        this.usernameParameter("username");
        this.passwordParameter("password");
    }

紧接着查看FormConfigure的usernameParameter()方法, 其作用就是将usernameParameter的值赋值给UsernamePasswordAuthenticationFilter对象的usernameParameter

    public FormLoginConfigurer<H> usernameParameter(String usernameParameter){
        ((UsernamePasswordAuthenticationFilter)this.getAuthenticationFilter()).setUsernameParameter(usernameParameter);
        return this;
        }

然后进入UsernamePasswordAuthenticationFilter类查看, 在上一步调用完它的setUsernameParameter()之后

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

已经将UsernamePasswordAuthenticationFilter对象的属性usernameParameter重新赋值了。然后就是查看此类的obtainUsername方法, 从request中,以usernameParameter为key,取出对应的值, 作为username. password也是同理.

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

登录回调

之前的这些配置, 前后端是否分离都没差别. 当在登录成功以后, 就要分情况讨论了

  • 前后端分离登录
  • 前后端不分离登录

此处先描述前后端不分离的情况

登录成功回调

在SpringSecurity中, 和登录成功重定向URL相关的方法有两个

  • defaultSuccessUrl
  • successForwardUrl

具体的方法源码可以查看FormLoginConfigure的父类AbstractAuthenticationFilterConfigurer

这个两个方法, 我们在配置的时候, 只需要配置其中一个就好了, 具体要配置哪个, 就看需求了

  1. defaultSuccessUrl 如果我们在defaultSuccessUrl中指定登录成功后的跳转页面为 /index, 此时分为两种情况
    • 如果是直接在浏览器输入登录地址, 然后登录成功, 就直接跳转到 /index
    • 如果是在浏览器输入其他地址, 如 http://localhost:8080/hello, 然后由于没有登录而重定向到登录页面, 然后登录成功 就不会来到 /index, 而是去到 /hello
  2. defaultSuccessUrl 还有一个重载方法, 加了一个boolean类型的参数.
    • 这个参数不设置, 默认为false, 此时就是和1中的情况一样
    • 如果设置为true, 则defaultSuccessUrl的效果和successForwardUrl一直.
  3. successForwardUrl 表示不管你从哪里来, 登录后一律跳转到 successForwardUrl 指定的地址 例如 successForwardUrl 指定的地址为 /index ,你在浏览器地址栏输入 http://localhost:8080/hello ,结果因为没有登录,重定向到登录页面, 当你登录成功之后,就会服务端跳转到 /index 页面;或者你直接就在浏览器输入了登录页面地址,登录成功后也是来到 /index。

注意:successForwardUrl是在登录成功后转发到 url,因为登录时的请求是 POST 的类型的,所以转发到 url时也是以POST方式去请求url的。

而 defaultSuccessUrl("/url",true) 这种方式就是以 GET 的方式重定向到 url

登录失败的回调

登录失败的回调也有两个

  • failureForwardUrl
  • failureUrl

这个两个方法, 也是只要配置一个就好了, 我个人觉得使用failureUrl较好

  • failureForwardUrl: 失败后转发到url, 如failureForwardUrl("/ffu")就是登录验证失败后转发到 /ffu 但是由于表单提交用的是POST请求, 所以使用failureForwardUrl(" /ffu") 转发到 /ffu, 这个 /ffu也需要是一个POST类型的接口
  • failureUrl: 失败后重定向到url, 如.failureUrl("/fu")就是登录验证失败后, 重定向到 /fu 这个 /fu 接口要求是GET类型的

简单粗暴的理解, 使用failureForwardUrl 登录校验失败后转发的接口地址, 要求是POST类型 failureUrl 重定向的接口地址, 要求是GET类型

注销登录

        http.logout()
        .logoutUrl("/logout")
        .logoutRequestMatcher(new AntPathRequestMatcher("/logout","GET"))
        .logoutSuccessUrl("/out")
        .deleteCookies()
        .clearAuthentication(true)
        .invalidateHttpSession(true)
        .permitAll();
  • logoutUrl: 默认的注销url就是 /logout, 可以通过配置logoutUrl("/xxx")来修改默认的
  • logoutRequestMatcher: 不仅可以修改默认的注销url, 还可以修改请求方式. 实际中这个方法和logoutUrl只要设置一个就好了
  • deleteCookies: 用来清除cookies
  • clearAuthentication 和 invalidateHttpSession 分别表示清除认证信息和使HttpSession失效, 默认可以不用配置, 默认就会被清除

前后端分离, 使用JSON交互

form-login3

在前后端分离的情况下, 认证这一块一般有两种处理方式: Session 和 Token Session可以理解为有状态的登录, 而Token则是无状态的登录

无状态登录

有状态登录-session

有状态登录, 即服务端需要记录每次会话的客户端信息, 从而识别客户端身份, 根据用户身份进行请求的处理, 典型的设计如Tomcat中的session. 例如用户登录后, 可以把用户信息保存在服务端的session中, 并且给用户一个cookie的值, 记录对应的session, 然后下次请求, 用户携带cookie值来(这一步由浏览器完成)发送请求, 这样我们就能识别到对应的session, 从而找到用户的信息.

session的一大优点就是方便, 基本不用做处理, 一切都是默认的就好了 但是它有一个致命的问题就是如果前端Android, IOS, 小程序这些, 它们天然就没有cookie, 如果非要用session, 就需要前端工程师在各自的 设备上做适配, 一般是模拟cookie 还有一些其他缺陷, 如

  • 服务端保存大量数据, 增加服务端压力
  • 服务端保存用户状态, 不方便集群化部署(一般是通过增加一台session服务器来解决)

无状态登录-token

无状态的大致意思是:

  • 服务端不保存任何客户端请求者信息
  • 客户端的每次请求必须具备自描述信息, 通过这些信息识别客户端

好处:

  • 客户端请求不依赖服务端的信息, 多次请求不需要必须访问到同一台服务器
  • 服务端可以任意的迁移和伸缩
  • 减小服务端压力

无状态登录流畅

  1. 首先客户端发生账户名/密码到服务端进行认证
  2. 认证通过后, 服务端将用户信息加密并且编码成一个 token, 返回给客户端
  3. 以后客户端每次发生请求, 都需要携带认证的token,
  4. 服务端对客户端请求中携带的token进行解密, 判断是否有效, 并且获取用户登录信息

登录交互

前后端分离的数据交互

在前后端分离这样的开发架构下, 前后端的交互都是通过JSON来进行的, 无论是登录成功还是失败, 都不会有服务端跳转或者客户端跳转之类

登录成功了, 服务端就返回一段登录成功的提示JSON给前端, 前端收到后, 后续操作由前端决定, 失败也是同理

登录成功

之前我们配置登录成功的处理是通过如下两个方法来配置的

  • defaultSuccessUrl
  • successForwardUrl 这两个都是配置跳转地址的, 适用于前后端不分离的开发. 除了这两个方法外, 还有一个就是 successHandler

successHandler的功能十分强大, 甚至已经囊括了 defaultSuccessUrl 和 successForwardUrl 的功能

                .successHandler((request,response,authentication)->{
        Object principal=authentication.getPrincipal();
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out=response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(principal));
        out.flush();
        out.close();
        })

successHandler 方法的参数是一个 AuthenticationSuccessHandler 对象,这个对象中我们要实现的方法是 onAuthenticationSuccess。 onAuthenticationSuccess方法一共有3个参数

  • HttpServletRequest
  • HttpServletResponse
  • Authentication

有了前两个参数request和response,我们可以选择服务端跳转,客户端跳转,或者直接返回JSON数据 第三个参数 Authentication则保存了刚刚登录的用户的信息 img.png

可见返回给前端的当前登录用户信息中,密码已经被擦除了... 具体在哪里擦除等后面分析源码的时候再找

登录失败

登录失败也有一个类似的回调,如下

                .failureHandler((request,response,exception)->{
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out=response.getWriter();
        out.write(exception.getMessage());
        out.flush();
        out.close();
        })

一二俩个参数就是HttpServletRequest和HttpResponse, 第三个参数就是登录失败的原因了,我们也可以通过挨个去对比exception,来返回更详细的失败原因

		if (e instanceof LockedException) {
            respBean.setMsg("账户被锁定,请联系管理员!");
        }
        elseif(e instanceof CredentialsExpiredException) {
            respBean.setMsg("密码过期,请联系管理员!");
        }
        elseif(e instanceof AccountExpiredException) {
            respBean.setMsg("账户过期,请联系管理员!");
        }
        elseif(e instanceof DisabledException) {
            respBean.setMsg("账户被禁用,请联系管理员!");
        }
        elseif(e instanceof BadCredentialsException) {
            respBean.setMsg("用户名或者密码输入错误,请重新输入!");
        }

这里有一个需要注意的点。 我们知道当用户登录时,用户名或密码输入错误,我们一般只给一个模糊的提示,即【用户名或密码错误,请重新输入】, 而不会给一个明确的诸如【用户名不存在】或者【密码输入错误】这样精确的提示。

在SpringSecurity中,用户名查找失败对应的异常是: UsernameNotFoundException。密码匹配失败对应的异常是:BadCredentialsException。但是我们在登陆失败的回调中,却总是看不到 UsernameNotFountException,无论是是用户名还是密码错误,抛出的异常都是BadCredentialsException。

原因是在登录校验用户名密码时,会进入 UsernamePasswordAuthenticationFilter#attemptAuthentication方法中, 在此方法的最后一句 return this.getAuthenticationManager().authenticate(authRequest); 调用了 authenticate方法, 重点就在此方法中。我们直接来到ProviderManager#authenticate 方法中,几乎所有认证的重要逻辑都在此方法中。 摘取此方法中关键的部分

    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        Authentication result = null;
        for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }
            result = provider.authenticate(authentication);
            if (result != null) {
                copyDetails(authentication, result);
                break;
            }
        }
        if (result == null && parent != null) {
            result = parentResult = parent.authenticate(authentication);
        }
        if (result != null) {
            if (eraseCredentialsAfterAuthentication
                    && (result instanceof CredentialsContainer)) {
                ((CredentialsContainer) result).eraseCredentials();
            }
            if (parentResult == null) {
                eventPublisher.publishAuthenticationSuccess(result);
            }
            return result;
        }
        throw lastException;
    }
  1. 首先获取authentication的class,判断当前的provider是否支持该authentication
  2. 如果支持,则调用provider的authenticate方法开始校验,检验完成后,会返回一个新的authentication
  3. 这里的provider可能有多个,如果provider的authenticate方法没能正常的返回一个Authentication,则调用provider的parent的authenticate方法继续校验
  4. copyDetails方法则用来把旧的Token的details属性拷贝到新的Token中
  5. 调用eraseCredentials方法擦除凭证信息,也就是密码。这个擦除方法比较简单,就是将Token中的credentials属性置空

实际进行调试可以发现

  1. getProviders() 默认只有一个Provider - AnonymousAuthenticationProvider,这个provider并不支持当前的authentication
  2. 然后会调用parent的authenticate方法,也就是DaoAuthenticationProvider的authenticate方法,不过DaoAuthenticationProvider没有重写authenticate方法, 所以具体方法实现还要看DaoAuthenticationProvider的父类AbstractUserDetailsAuthenticationProvider的authenticate方法。 截取一部分方法,可见将UsernameNotFoundException异常封装,然后抛出BadCredentialsException
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.onlySupports",
                        "Only UsernamePasswordAuthenticationToken is supported"));

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                : authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;

            try {
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
            } catch (UsernameNotFoundException notFound) {
                logger.debug("User '" + username + "' not found");

                if (hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.badCredentials",
                            "Bad credentials"));
                } else {
                    throw notFound;
                }
            }

            Assert.notNull(user,
                    "retrieveUser returned null - a violation of the interface contract");
        }

        // ....
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

未认证处理方案

在没有认证就访问接口页面的,在前后端不分离的情况下,直接重定向到登录页就好了。 但是在前后端分离的情况下,就不能这样处理了。此时后端不能直接进行重定向,而是返回一段JSON提示用户未登录给前端,让前端决定页面跳转。

在默认情况下,未登录就访问接口,会调用 public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean 类的commence方法来处理,默认重定向的登录页。

可以通过实现 AuthenticationEntryPoint接口 重写 commence方法来替换策略

                .and()
        .csrf().disable()
        .exceptionHandling()
        .authenticationEntryPoint((request,response,authException)->{
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out=response.getWriter();
        out.write("尚未登录,请先登录");
        out.flush();
        out.close();
        });

注销登录

原先也是跳转页面,现在直接返回一个json,具体跳转逻辑由前端决定

        http.logout()
        .logoutUrl("/logout")
        .logoutSuccessHandler((request,response,authentication)->{
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out=response.getWriter();
        out.write("注销成功");
        out.flush();
        out.close();
        })
        .deleteCookies();

基于内存用户的授权

1.授权

所谓授权,就是用户如果要访问某一个资源,我们需要去检查用户是否具备这样的权限,如果具备就允许访问,如果不具备,则不允许访问。

2.准备测试用户

由于当前还没有连接数据库,所以测试用户还是基于内存来配置。 基于内存配置用户有两种方式,第一种就是和之前一样,在SecurityConfig类中重写 configure(AuthenticationManagerBuilder auth) 方法

    @Override
	protected void configure(AuthenticationManagerBuilder auth)throws Exception{
        auth.inMemoryAuthentication()
        .withUser("spencer")
        .password("123")
        .roles("admin")
        .and()
        .withUser("maqb")
        .password("123")
        .roles("user");
    }

还有一种就是重写 userDetailsService()方法,注意要加一个@Bean注解

    @Bean
    @Override
    protected UserDetailsService userDetailsService(){
        InMemoryUserDetailsManager manager=new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
        manager.createUser(User.withUsername("maqb").password("123").roles("user").build());
        return manager;
    }

3.准备测试接口


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

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

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

如上准备了3个测试接口,规划是这样的

  1. /hello 是任何身份都可以访问的
  2. /admin/hello 是具有admin身份的人才可以访问的
  3. /user/hello 是具有user身份的人才能访问的
  4. 所有user可以访问的资源,admin都可以访问

「注意第四条规范意味着所有具备 admin 身份的人自动具备 user 身份。」

4.配置

接下来我们来配置权限的拦截规则,在 Spring Security 的 configure(HttpSecurity http) 方法中,代码如下: 注意 anyRequest() 一定要放在最后, 查看配置源码 AbstractRequestMatcherRegistry类 可知: 在任何拦截规则之前(包括 anyRequest 自身),都会先判断 anyRequest 是否已经配置,如果已经配置,则会抛出异常,系统启动失败。

    @Override
	protected void configure(HttpSecurity http)throws Exception{
        http.authorizeRequests()
        .antMatchers("/admin/**").hasRole("admin")
        .antMatchers("/user/**").hasRole("user")
        .and()
        .authorizeRequests().anyRequest().authenticated();

        // ...
    }

这里的匹配规则我们采用了 Ant 风格的路径匹配符,Ant 风格的路径匹配符在 Spring 家族中使用非常广泛,它的匹配规则也非常简单:

通配符 含义
** 匹配多层路径
* 匹配一层路径
? 匹配任意单个字符

5.启动测试

在使用maqb用户登录后(角色为user),可以访问

  • /hello
  • /user/hello

不可以访问

  • /admin/hello 403

在使用spencer用户登录后(角色为admin),可以访问

  • /hello
  • /admin/hello

不可以访问

  • /user/hello 403

这与我们最初设想的admin用户可以访问/user/hello 不符合 关于这一个问题,可以通过修改授权设置来解决,直接设置 /user/** 可以让user和admin都可以访问

.antMatchers("/user/**").hasRole("user") // 修改成如下
        .antMatchers("/user/**").hasAnyRole("user","admin")

还有一种方法就是角色继承

6.角色继承

所有user角色能访问的资源,admin都可以访问,可以通过角色继承来实现 在SpringSecurity中,我们只需要在SecurityConfig中添加一个配置就好了

    @Bean
    RoleHierarchy roleHierarchy(){
            RoleHierarchyImpl roleHierarchy=new RoleHierarchyImpl();
            roleHierarchy.setHierarchy("ROLE_admin > ROLE_user");
            return roleHierarchy;
    }

注意要加上前缀 ROLE_ 此时再重启项目,admin角色也可以访问 /user/hello

将用户存入数据库

UserDetailsService

SpringSecurity支持多种不同的数据源,这些不同的数据源最终都被封装成 UserDetailsService 的实例。 我们可以自己实现一个UserDetailsService,也可以使用SpringSecurity自带的,如上一节用到的InMemoryUserDetailsManager。 如下图展示了UserDetailsService的继承结构 UserDetailsService.png

除了InMemoryUserDetailsManager之外,还可以使用 JdbcUserDetailsManager,JdbcUserDetailsManager可以通过JDBC的方式将数据库和SpringSecurity连接起来

JdbcUserDetailsManager

JdbcUserDetailsManager 自己提供了一个数据库模型,这个模型的位置保存在 org/springframework/security/core/userdetails/jdbc/users.ddl 具体的脚本内容如下

create table users
(
    username varchar_ignorecase(50) not null primary key,
    password varchar_ignorecase(500) not null,
    enabled  boolean not null
);
create table authorities
(
    username  varchar_ignorecase(50) not null,
    authority varchar_ignorecase(50) not null,
    constraint fk_authorities_users foreign key (username) references users (username)
);
create
unique index ix_auth_username on authorities (username,authority);

可以看到,脚本中有一种数据类型 varchar_ignorecase,这个其实是针对 HSQLDB 数据库创建的,而我们使用的 MySQL 并不支持这种数据类型,所以这里需要手动调整一下数据类型,将 varchar_ignorecase 改为 varchar 即可。 修改完之后,执行脚本,生成两个表

  • users表中保存用户的基本信息,包括用户名、密码、账户是否可用。
  • authorities表中保存了用户的角色
  • 两个表通过username连接

连接数据库

在完成数据库表的创建之后,使用JdbcUserDetailsManager替换InMemoryUserDetailsManager。

  1. 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
  1. 添加数据连接配置
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring_security_sample?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: admin
  1. 修改SecurityConfig配置类 启动的时候,如果spencer和maqb两个用户不存在,则创建他们
    @Autowired
    DataSource dataSource;

    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        JdbcUserDetailsManager manager = new JdbcUserDetailsManager();
        manager.setDataSource(dataSource);
        if (!manager.userExists("spencer")) {
            manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
        }
        if (!manager.userExists("maqb")) {
            manager.createUser(User.withUsername("maqb").password("123").roles("user").build());
        }
        return manager;
    }

启动项目,可见数据库表中自动添加了数据 img3.png

测试

项目启动后,用户可以登录 img4.png spencer用户三个接口都可以访问, maqb用户只能访问 /hello 和 /user/hello

SpringSecurity+JPA

上一节已经将用户保存到了数据库中,但是使用的是JDBC,还是不太方便,下面就引入JPA来完成数据库操作。(Mybatis也是同理)

1.创建工程

创建一个Maven或者Springboot项目,添加需要的依赖。 再在数据库中创建一个新的库 withjpa

2.准备模型

准备两个实体类,分别代表用户和角色。

用户角色:


@Entity(name = "t_role")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String nameZh;

    // getter/setter 
}

用户实体:


@Entity(name = "t_user")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
    private List<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : getRoles()) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    // getter/setter
}

用户实体类实现了 UserDetails 接口,此接口用于提供用户的核心信息。 稍微解释下此类的字段:

  1. accountNonExpired、accountNonLocked、credentialsNonExpired、enabled这四个属性分别用来表示账户是否没有过期、 账户是否没有被锁定、密码是否没过期、账户是否可用。
  2. roles 属性表示用于的角色,User和Role是多对多
  3. getAuthorities 方法返回用户的权限信息,此处就直接将角色稍微处理下返回了

3.配置

  1. 数据模型准备好之后,再来自定义一个 UserDao
public interface UserDao extends JpaRepository<User, Long> {
    User findUserByUsername(String username);
}
  1. 定义UserService UserService实现了UserDetailsService接口,要实现loadUserByUsername方法。 这个方法的参数就是登录时传入的用户名,根据此用户名去数据库查出用户,然后进行密码对比(自动完成)

@Service
public class UserService implements UserDetailsService {

    @Resource
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userDao.findUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        return user;
    }
}

3.修改SecurityConfig配置类 去掉之前的基于内存的配置和JdbcUserDetailsManager相关的配置,加上如下配置。

    @Autowired
    UserService userService;

@Override
protected void configure(AuthenticationManagerBuilder auth)throws Exception{
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
        }
  1. 添加yml配置
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/withjpa?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: admin

  jpa:
    database: mysql
    #database-platform: mysql
    hibernate:
      ddl-auto: update
    show-sql: true

4.测试

先使用测试单元,给数据库添加一些数据


@SpringBootTest
public class WithJpaApplicationTests {

    @Resource
    UserDao userDao;

    @Test
    void contextLoads() {
        User u1 = new User();
        u1.setUsername("spencer");
        u1.setPassword("123");
        u1.setAccountNonExpired(true);
        u1.setAccountNonLocked(true);
        u1.setCredentialsNonExpired(true);
        u1.setEnabled(true);
        List<Role> rs1 = new ArrayList<>();
        Role r1 = new Role();
        r1.setName("ROLE_admin");
        r1.setNameZh("管理员");
        rs1.add(r1);
        u1.setRoles(rs1);
        userDao.save(u1);
        User u2 = new User();
        u2.setUsername("maqb");
        u2.setPassword("123");
        u2.setAccountNonExpired(true);
        u2.setAccountNonLocked(true);
        u2.setCredentialsNonExpired(true);
        u2.setEnabled(true);
        List<Role> rs2 = new ArrayList<>();
        Role r2 = new Role();
        r2.setName("ROLE_user");
        r2.setNameZh("普通用户");
        rs2.add(r2);
        u2.setRoles(rs2);
        userDao.save(u2);
    }
}

执行完成之后可以看见数据库withjpa库表中已经有了数据 img5.png

再有了数据之后,就可以启动项目测试了。启动之后,可以成功登录。 并且登录成功后, admin角色的用户 /hello 、/user/hello 、/admin/hello三个接口都可以访问 img6.png

自动登录

自动登录是一个非常常见的功能,毕竟每次都要输入用户名和密码很麻烦。 自动登录的功能就是,用户在登录成功之后,在某一段时间内,如果用户关闭了浏览器重新打开,或者服务器重启了,都不需要用户重新登录, 用户依然可以直接访问资源。

作为一个常见的功能,SpringSecurity也已经提供了相应的支持,先看下SpringSecurity如何实现这个功能。

实战代码

其实只要在SpringSecurity配置类中加一句 .rememberMe() 就好了

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();

        http.formLogin()
                .and()
                .rememberMe()
                .and()
                .csrf().disable();
    }

然后随意来一个测试接口


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

启动项目输入localhost:8080/hello ,会自动跳转到登录页,在默认的登录页,可以看到有个rememberMe的选项 img7.png

输入用户名、密码,选中remember Me,然后登录,查看登录请求所携带的参数。如果要自定义页面,注意rememberMe的参数名字

img8.png

在登录成功之后,就会自动跳转到 hello 接口了。注意,系统访问 hello 时自动携带的 cookie img9.png 可以看到这里多了一个remember-me,这就是实现的核心,关于这个remember-me稍后再看原理,先来测试效果。 我们关闭浏览器后,再重新打开,直接访问 hello 接口,发现直接就访问到了,不需要重新登录。说明RememberMe生效了。 (不过此时重启服务,浏览器访问hello接口还是要重新登录的)

原理分析

将访问hello时cookie中的 remember-me 的值复制出来,这是一个Base64编码后的字符串,对其进行解码。

public class DecodeTest {
    @Test
    void t1() {
        Base64.Decoder decoder = Base64.getDecoder();
        String str = new String(decoder.decode("c3BlbmNlcjoxNjE4MjMyMzU0MTYzOmZjMmEwODllY2UzMWU1ZWE0NTM5NDExOGI0NTU3NGI1"));
        System.out.println(str); // spencer:1618232354163:fc2a089ece31e5ea45394118b45574b5
    }
}

可见解码后的字符串,是由三部分组成的,中间由:隔开。 spencer:1618232354163:fc2a089ece31e5ea45394118b45574b5

  1. 第一段比较明显,是登录的用户名
  2. 第二段是一个时间戳,通过在线时间戳转换后发现,是一个两周后的时间
  3. 第三段是使用MD5散列函数算出来的值,它的明文格式是 username + ":" + tokenExpiryTime + ":" + password + ":" + key 最后的key是一个散列盐值,可以防止令牌被修改

在了解完cookie中 remember-me 的含义之后,对于记住的登录流程也大致可以猜到了。

在浏览器关闭重新打开后,用户再去访问 hello 接口,此时会携带 cookie 中的 remember-me 到服务端。 服务端拿到值后,可以方便的计算出用户名和过期时间,若是还在有效期内,则使用用户名查询到用户密码, 然后通过MD5散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。

源码分析

这里主要从两个方面来介绍,一个是 remember-me 这个令牌的生成过程,另一个则是它的解析过程

1. 生成

生成的核心处理方法在:TokenBasedRememberMeServices#onLoginSuccess

    @Override
    public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
                               Authentication successfulAuthentication) {

        String username = retrieveUserName(successfulAuthentication);
        String password = retrievePassword(successfulAuthentication);

        // If unable to find a username and password, just abort as
        // TokenBasedRememberMeServices is
        // unable to construct a valid token in this case.
        if (!StringUtils.hasLength(username)) {
            logger.debug("Unable to retrieve username");
            return;
        }

        if (!StringUtils.hasLength(password)) {
            UserDetails user = getUserDetailsService().loadUserByUsername(username);
            password = user.getPassword();

            if (!StringUtils.hasLength(password)) {
                logger.debug("Unable to obtain password for user: " + username);
                return;
            }
        }

        int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
        long expiryTime = System.currentTimeMillis();
        // SEC-949
        expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);

        String signatureValue = makeTokenSignature(expiryTime, username, password);

        setCookie(new String[]{username, Long.toString(expiryTime), signatureValue},
                tokenLifetime, request, response);

        if (logger.isDebugEnabled()) {
            logger.debug("Added remember-me cookie for user '" + username
                    + "', expiry: '" + new Date(expiryTime) + "'");
        }
    }

    protected String makeTokenSignature(long tokenExpiryTime, String username,
                                        String password) {
        String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
        MessageDigest digest;
        try {
            digest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("No MD5 algorithm available!");
        }

        return new String(Hex.encode(digest.digest(data.getBytes())));
    }

这个方法比较好理解:

  1. 登录成功后从Authentication中取出用户名和密码
  2. 由于登录成功之后,密码可能被擦除了。所以,如果一开始没有拿到密码,就从UserDetailsService中重新加载用户重新获取密码
  3. 接下来就是获取令牌的有效期,默认是两周时间。
  4. 在接下来调用 makeTokenSignature 方法计算散列值。实际上就是根据username、令牌有效期、password、key 一起去计算一个散列值。 如果没有自定义key的值,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。
  5. 最后将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中

对第四点补充下: 由于我们没有自己设置 key,key的默认值是一个UUID字符串,这样会带来一个问题,就是如果服务端重启,这个key就会变, 这个样就会导致之前派发出去的 remember-me 自动登录令牌失效,所以,我们可以指定这个 key。指定方式如下:

    @Override
	protected void configure(HttpSecurity http)throws Exception{
        http.authorizeRequests().anyRequest().authenticated();

        http.formLogin()
        .and()
        .rememberMe()
        .key("maqb")
        .and()
        .csrf().disable();
    }

具体从登录如何走到这里的,大致流程如下

AbstractAuthenticationProcessingFilter#doFilter -> 
AbstractAuthenticationProcessingFilter#successfulAuthentication -> 
AbstractRememberMeServices#loginSuccess -> 
TokenBasedRememberMeServices#onLoginSuccess。

2. 解析

解析方法在 RememberMeAuthenticationFilter#doFilter 中

    public void doFilter(ServletRequest req,ServletResponse res,FilterChain chain)
        throws IOException,ServletException{
        HttpServletRequest request=(HttpServletRequest)req;
        HttpServletResponse response=(HttpServletResponse)res;

        if(SecurityContextHolder.getContext().getAuthentication()==null){
        Authentication rememberMeAuth=rememberMeServices.autoLogin(request,
        response);

        // ...略
        }
    }

SecurityContextHolder.getContext().getAuthentication() 无法获取登录用户的时候,调用autoLogin方法 再来查看autoLogin方法

	@Override
    public final Authentication autoLogin(HttpServletRequest request,
                                          HttpServletResponse response) {
        String rememberMeCookie = extractRememberMeCookie(request);

        if (rememberMeCookie == null) {
            return null;
        }

        logger.debug("Remember-me cookie detected");

        if (rememberMeCookie.length() == 0) {
            logger.debug("Cookie was empty");
            cancelCookie(request, response);
            return null;
        }

        UserDetails user = null;

        try {
            String[] cookieTokens = decodeCookie(rememberMeCookie);
            user = processAutoLoginCookie(cookieTokens, request, response);
            userDetailsChecker.check(user);
            // ...略
        }
    }

这个方法会从requestHeader中获取remember-me这个cookie的值,然后使用decodeCookie方法进行解码。 解码之后,再调用 processAutoLoginCookie

方法去做校验,processAutoLoginCookie 方法的代码我就不贴了,核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5

散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。

自动登录-降低安全风险

在上一节的自动登陆中,我们自定义了key,使得就算服务器重启,也可以使用旧的令牌登录(remember-me 的值)。 如果这个令牌泄露,被别人拿到,那么也就可以随意访问了。

下面列举两种降低风险的方法

  1. 持久化令牌方案
  2. 二次校验

1 持久化令牌

1.1 基本原理

持久化令牌在基本的自动登录功能基础上,又增加了新的校验参数,一个是 series,另一个是token。 其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新。而token只要有新的会话,就会重新生成。 这样就可以避免一个用户同时多端在线,就像手机QQ,一个手机上登录了,就会踢掉另一个手机的登录,这样用户很容易就可以发现账户是否泄露。

持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices。而上一节的自动化登录使用的是TokenBasedRmemeberMeServices。 它们都是AbstractRememberMeServices的子类。 RememberMeServices.png

用来保存令牌的处理类是 PersistentRmemeberMeToken,字段date表示上一次自动登录的时间。

public class PersistentRememberMeToken {
    private final String username;
    private final String series;
    private final String tokenValue;
    private final Date date;
    // ...
}

1.2 代码演示

首先我们需要一张表来记录令牌信息,这张表可以自定义,也可以使用系统默认提供的JDBC来操作,如果使用默认的JDBC, 即 JdbcTokenRepositoryImpl。来看下此类的默认定义的SQL语句

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
        PersistentTokenRepository {
    public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
            + "token varchar(64) not null, last_used timestamp not null)";
    public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
    public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
    public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
    public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";

    // ...
}

整理下就是如下的SQL

CREATE TABLE `persistent_logins`
(
    `username`  varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    `series`    varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    `token`     varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    `last_used` timestamp                              NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

先直接在Mysql中执行语句创建好表。

然后给项目添加依赖


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

再修改yaml,添加数据库连接的配置

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: admin
    url: jdbc:mysql://localhost:3306/withjpa?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

最后在SecurityConfig类中配置 JdbcTokenRepositoryImpl

	@Autowired
    DataSource dataSource;

    @Bean
    JdbcTokenRepositoryImpl jdbcTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();

        http.formLogin()
                .and()
                .rememberMe()
                .key("maqb")
                .tokenRepository(jdbcTokenRepository())
                .and()
                .csrf().disable();
    }

提供一个 JdbcTokenRepositoryImpl 实例,并给其配置 DataSource 数据源,最后通过 tokenRepository 将 JdbcTokenRepositoryImpl 实例纳入配置中。

1.3 测试

还是去访问 /hello 接口,此时会自动跳转到登录页面,输入用户名、密码,勾选记住我这个选项,登录成功后成功访问到 /hello。 img.png

将remember-me的字符串取出来,再次使用Base64解码,得到如下字符串: wkuRheVGuu8wNfC%2FKiXflg%3D%3D:bQBHqMWp9YJGlehJco4I1A%3D%3D

其中 %2f 表示 / %3D 表示 =,所以上面的字符串实际上是 wkuRheVGuu8wNfC/KiXflg==:bQBHqMWp9YJGlehJco4I1A==

此时再去数据库查看,可以看到有一条持久化的登录令牌数据,其中的series就是上面数字:的前半段, 而token就是:的后半段 img11.png

此时我们再重启服务器,直接访问 /hello 接口,没有问题,可以直接访问。

然后再来查看数据库的数据,发现token和last_used变化了 img12.png

1.4 源码分析

此处的流程和自动登录那里的流程是一样的,只是具体的实现类不同了。

首先是 生成过程:

也是先从 AbstractAuthenticationProcessingFilter#doFilter 开始,进入此方法的最后一句 successfulAuthentication(request, response, chain, authResult);

会进入此类的successfulAuthentication方法中,当执行到此方法的 rememberMeServices.loginSuccess(request, response, authResult);

就会进入 PersistentTokenBasedRememberMeServices 的 loginSuccess 方法中

来仔细看下此方法

	protected void onLoginSuccess(HttpServletRequest request,
                                  HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();

        logger.debug("Creating new persistent login for user " + username);

        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
                username, generateSeriesData(), generateTokenData(), new Date());
        try {
            tokenRepository.createNewToken(persistentToken);
            addCookie(persistentToken, request, response);
        } catch (Exception e) {
            logger.error("Failed to save persistent token ", e);
        }
    }

此方法可以看到

  1. 在登录成功后,先获取到用户名
  2. 接下来构造一个 PersistentRememberMeToken 实例,使用generateSeriesData()和generateTokenData()来生成series和token, 具体的生成过程实际上就是调用 SecureRandom 生成随机数再进行 Base64 编码,不同于我们以前用的 Math.random 或者 java.util.Random 这种伪随机数, SecureRandom 则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。
  3. 调用 tokenRepository.createNewToken方法,这个tokenRepository就是一开始配置的JdbcTokenRepositoryImpl,这个createNewToken方法其实就是将数据存入数据库中
  4. addCookie 就是将series和token加到cookie中。

然后就是 校验 的过程了,也在 PersistentTokenBasedRememberMeService 这个类中

protected UserDetails processAutoLoginCookie(String[]cookieTokens,
                                             HttpServletRequest request,HttpServletResponse response){

    if(cookieTokens.length!=2){
        throw new InvalidCookieException("Cookie token did not contain "+2
                                         +" tokens, but contained '"+Arrays.asList(cookieTokens)+"'");
    }

    final String presentedSeries=cookieTokens[0];
    final String presentedToken=cookieTokens[1];

    PersistentRememberMeToken token=tokenRepository
        .getTokenForSeries(presentedSeries);

    if(token==null){
        // No series match, so we can't authenticate using this cookie
        throw new RememberMeAuthenticationException(
            "No persistent token found for series id: "+presentedSeries);
    }

    // We have a match for this user/series combination
    if(!presentedToken.equals(token.getTokenValue())){
        // Token doesn't match series value. Delete all logins for this user and throw
        // an exception to warn them.
        tokenRepository.removeUserTokens(token.getUsername());

        throw new CookieTheftException(
            messages.getMessage(
                "PersistentTokenBasedRememberMeServices.cookieStolen",
                "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
    }

    if(token.getDate().getTime()+getTokenValiditySeconds()*1000L<System
       .currentTimeMillis()){
        throw new RememberMeAuthenticationException("Remember-me login has expired");
    }

    // Token also matches, so login is valid. Update the token value, keeping the
    // *same* series number.
    if(logger.isDebugEnabled()){
        logger.debug("Refreshing persistent login token for user '"
                     +token.getUsername()+"', series '"+token.getSeries()+"'");
    }

    PersistentRememberMeToken newToken=new PersistentRememberMeToken(
        token.getUsername(),token.getSeries(),generateTokenData(),new Date());

    try{
        tokenRepository.updateToken(newToken.getSeries(),newToken.getTokenValue(),
                                    newToken.getDate());
        addCookie(newToken,request,response);
    }
    catch(Exception e){
        logger.error("Failed to update token: ",e);
        throw new RememberMeAuthenticationException(
            "Autologin failed due to data access problem");
    }

    return getUserDetailsService().loadUserByUsername(token.getUsername());
}

处理逻辑大致如下:

  1. 从request中取出cookie,解析出 series 和 token
  2. 根据 series,去数据库查找数据,对应一个 PersistentRememberMeToken 实例
  3. 判断如果数据库里的 token 和 cookie 中的 token 值不等的话,就说明账号可能被别人盗用,此时根据用户名移除相关的 token,相当于必须要重新输入用户名密码登录才能获取新的自动登录权限。
  4. 接下来校验 token 是否过期。
  5. 构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token(这就是我们文章开头说的,新的会话都会对应一个新的 token)。
  6. 将新的令牌重新添加到 cookie 中返回。
  7. 根据用户名查询用户信息,再走一波登录流程。

不过根据我的测试,对于上面的第三点,如果两个token不相等的话,确实是会删除数据库中相关 用户名的token,但是此时还是可以不重新登录访问。 因为 /hello 请求中还带有一个JSessionId,在SecurityContextPersistenceFilter这个过滤器中,会根据JSessionId找到对应的session,从而取出在session中的登录的用户信息(如果服务器没重启、session没过期)

如果想要达到预期的效果,得让session过期了才行.. 或者让前端能不能设置,请求不带上jsessionId

2. 二次校验

上面持久化令牌的方式,我的实际测试达不到想要的效果,不知道是不是我少了什么配置.....

二次校验简单来说就是:如果用户使用了自动登录,我们就只让他做一些常规的、不敏感的操作,例如一些数据的浏览什么的。但是如果要做一些修改、新增、删除操作,我们就需要跳转回登录页面,让其登陆后再继续操作。

先写三个接口


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

    @GetMapping("/admin")
    public String admin() {
        return "admin";
    }

    @GetMapping("/rememberme")
    public String rememberMe() {
        return "rememberMe";
    }
}
  • 第一个接口 /hello ,只要认证后就可以访问,无论是用户名密码认证还是自动登录认证。
  • 第二个 /admin,只有用户名密码登录后才可以访问,如果是自动登录认证的,必须重新输入用户名密码登录
  • 第三个 /rememberme,必须是通过自动登录认证后才能访问,如果是通过用户名密码认证的,则无法访问。

核心就是再SecurityConfig中配置

        http.authorizeRequests()
        .antMatchers("/rememberme").rememberMe()
        .antMatchers("/admin").fullyAuthenticated()
        .anyRequest().authenticated();

自定义认证逻辑

在大部分的程序中,不会只存在用户名/密码登录这一种方式,一般都还有手机号验证码登录、邮箱登录等。 所谓自定义认证逻辑,就是自定义认证规则来实现我们需要的认证方式。

1. 添加验证码功能-方式一

首先的话先来一个添加验证码功能,就是在输完用户名、密码然后下面的图片验证码。

1.1 创建验证码

要实现验证码功能,首先得有验证码。验证码的实现方案网上有很多方案,这里使用一个工具类生成。

/**
 * 生成验证码工具类
 */
public class VerifyCode {
    private int width = 100;// 生成验证码图片的宽度
    private int height = 50;// 生成验证码图片的高度
    private String[] fontNames = {"宋体", "楷体", "隶书", "微软雅黑"};
    private Color bgColor = new Color(255, 255, 255);// 定义验证码图片的背景颜色为白色
    private Random random = new Random();
    private String codes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private String text;// 记录随机字符串

    /**
     * 获取一个随意颜色
     *
     * @return
     */
    private Color randomColor() {
        int red = random.nextInt(150);
        int green = random.nextInt(150);
        int blue = random.nextInt(150);
        return new Color(red, green, blue);
    }

    /**
     * 获取一个随机字体
     *
     * @return
     */
    private Font randomFont() {
        String name = fontNames[random.nextInt(fontNames.length)];
        int style = random.nextInt(4);
        int size = random.nextInt(5) + 24;
        return new Font(name, style, size);
    }

    /**
     * 获取一个随机字符
     *
     * @return
     */
    private char randomChar() {
        return codes.charAt(random.nextInt(codes.length()));
    }

    /**
     * 创建一个空白的BufferedImage对象
     *
     * @return
     */
    private BufferedImage createImage() {
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g2 = (Graphics2D) image.getGraphics();
        g2.setColor(bgColor);// 设置验证码图片的背景颜色
        g2.fillRect(0, 0, width, height);
        return image;
    }

    public BufferedImage getImage() {
        BufferedImage image = createImage();
        Graphics2D g2 = (Graphics2D) image.getGraphics();
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 4; i++) {
            String s = randomChar() + "";
            sb.append(s);
            g2.setColor(randomColor());
            g2.setFont(randomFont());
            float x = i * width * 1.0f / 4;
            g2.drawString(s, x, height - 15);
        }
        this.text = sb.toString();
        drawLine(image);
        return image;
    }

    /**
     * 绘制干扰线
     *
     * @param image
     */
    private void drawLine(BufferedImage image) {
        Graphics2D g2 = (Graphics2D) image.getGraphics();
        int num = 5;
        for (int i = 0; i < num; i++) {
            int x1 = random.nextInt(width);
            int y1 = random.nextInt(height);
            int x2 = random.nextInt(width);
            int y2 = random.nextInt(height);
            g2.setColor(randomColor());
            g2.setStroke(new BasicStroke(1.5f));
            g2.drawLine(x1, y1, x2, y2);
        }
    }

    public String getText() {
        return text;
    }

    public static void output(BufferedImage image, OutputStream out) throws IOException {
        ImageIO.write(image, "JPEG", out);
    }
}

写一个接口,生成验证码,将验证码保存在session中,然后将验证码图片返回给前端


@RestController
public class VerifyCodeController {
    @GetMapping("/verifyCode")
    public void code(HttpServletRequest request, HttpServletResponse response) throws IOException {
        VerifyCode vc = new VerifyCode();
        BufferedImage image = vc.getImage();
        String text = vc.getText();
        HttpSession session = request.getSession();
        session.setAttribute("index_code", text);
        VerifyCode.output(image, response.getOutputStream());
    }
}

写一个简单的html页面测试 图片验证码


<body>
<img src="/verifyCode" alt=""/>
</body>

img13.png

1.2 自定义过滤器


@Component
public class VerifyCodeFilter extends GenericFilterBean {

    private final String defaultLoginProcessingUrl = "/login";

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;

        // 判断当前请求是否为登录请求
        if ("POST".equalsIgnoreCase(req.getMethod()) && defaultLoginProcessingUrl.equals(req.getServletPath())) {
            // 校验验证码
            String code = request.getParameter("code");
            HttpSession session = req.getSession();
            if (session == null) {
                authenticationFailureHandler.onAuthenticationFailure(req, resp, new VerifyInvalidException("验证码错误"));
            }

            String codeInSession = (String) session.getAttribute("index_code");
            if (StringUtils.isEmpty(code)) {
                authenticationFailureHandler.onAuthenticationFailure(req, resp, new VerifyInvalidException("验证码不能为空"));
            }
            if (!codeInSession.equalsIgnoreCase(code)) {
                authenticationFailureHandler.onAuthenticationFailure(req, resp, new VerifyInvalidException("验证码错误"));
            }
        }
        chain.doFilter(req, resp);
    }
}

关于这个过滤器简单的解释下:

  1. defaultLoginProcessingUrl 表示要进行登录的请求,需要和自己定义的一致。
  2. 从请求中读取session,并且从session中读取保存的验证码。
  3. 读取请求中携带的验证码,将两者匹配。若是不想等,或者没有传验证码,则直接认为登录失败。

关于第三点,我在验证码校验失败这一块自定义了一个VerifyInvalidException异常,用户抛出验证码错误、验证码不能为空的错误。

public class VerifyInvalidException extends AuthenticationException {

    public VerifyInvalidException(String msg, Throwable t) {
        super(msg, t);
    }

    public VerifyInvalidException(String msg) {
        super(msg);
    }
}

此处我注入了一个 AuthenticationFailureHandler:认证失败的处理器,具体这个bean稍后讲。

1.3 配置拦截器、定义失败处理器

在SecurityConfig配置类中,首先将我们自定义的过滤器加到SpringSecurity的过滤链中,放在UsernamePasswordAuthenticationFilter前面。

    protected void configure(HttpSecurity http)throws Exception{
        http.addFilterBefore(verifyCodeFilter,UsernamePasswordAuthenticationFilter.class);

然后自定义一个 AuthenticationFailureHandler,用于处理认证失败的返回。

@Bean
public AuthenticationFailureHandler authenticationFailureHandler(){
    return(request,response,exception)->{
        response.setContentType("application/json;charset=utf-8");
        response.setStatus(500);
        PrintWriter writer=response.getWriter();
        String responseMsg=null;
        if(exception instanceof VerifyInvalidException){
            responseMsg=exception.getMessage();
        }else if(exception instanceof BadCredentialsException){
            responseMsg="用户名或密码错误,请重新输入";
        }else if(exception instanceof LockedException){
            responseMsg="账户被锁定,请联系管理员";
        }else if(exception instanceof DisabledException){
            responseMsg="账户被禁用,请联系管理员";
        }else if(exception instanceof AccountExpiredException){
            responseMsg="账户过期,请联系管理员";
        }else{
            responseMsg="账户出现问题,请联系管理员....";
        }
        writer.write(responseMsg);
        writer.flush();
        writer.close();
};

然后将这个处理器配置到SpringSecurity中

.failureHandler(authenticationFailureHandler())

1.4 测试

自定义登录页,写一个简单的登录页


<form action="/login" method="post">
    用户名:<input type="text" name="username"> <br/>
    密码:<input type="password" name="password"> <br/>
    验证码:<input type="text" name="code"> <br/>
    <img src="/verifyCode" alt=""> <br/>
    <input type="submit" value="提交">
</form>

启动项目,访问登录页:

img14.png

当不输入验证码时:

img15.png

当验证码输入错误时:

img16.png

2. 添加验证码功能-方式二

在方式一中,使用了自定义一个过滤器的方法来实现验证码功能。但是这样写有一个问题,就是将自定义的VerifyCodeFilter添加到SpringSecurity的过滤链之后, 每个请求过来,都会经过一次这个过滤器,虽然问题也不是很大,但是总感觉这样不太合适。

下面换一种方式来添加验证码功能。在此之前,先来简单梳理下登录的流程。

2.1 登录流程分析

SpringSecurity核心是由许多的过滤器组成的,这其中有一个 UsernamePasswordAuthenticationFilter ,就是用于默认的用户名/密码方法的登录校验的。

当我们发送了一个 POST /login 的登录请求,带上了 username 和 password,在经过一系列的过滤器之后会到达 UsernamePasswordAuthenticationFilter#doFilter, 由于UsernamePasswordAuthenticationFilter 没有重写父类 AbstractAuthenticationProcessingFilter#doFilter,所以最终会来到 Abstract..这个类的doFilter方法中。

看下这个doFilter方法

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{
        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);
}
  1. 首先判断当前请求是不是登录请求 requiresAuthentication(request, response),不是的话就直接放行
  2. 若是登录请求,则尝试进行校验 attemptAuthentication(request, response)
  3. 如果校验成功,先进行 sessionStrategy.onAuthentication(authResult, request, response);,这一步貌似与防止session攻击有关,不太了解。
  4. 如果校验失败,则进行失败处理 unsuccessfulAuthentication(request, response, failed);,其实就是调用failureHandler进行处理
  5. 在 3. 成功的基础上,最后会进行 successfulAuthentication(request, response, chain, authResult);,就是跳转到我们所配置的登录成功的处理。 比如我在这一节配置的是.defaultSuccessUrl("/loginSuccess", true),则就会跳转到 /loginSuccess 。

来看下2的 attemptAuthentication 方法,我省略了一部分。

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);

    UsernamePasswordAuthenticationToken authRequest=new UsernamePasswordAuthenticationToken(
        username,password);

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

    return this.getAuthenticationManager().authenticate(authRequest);
}
  1. 先判断下是不是POST类型
  2. 从request中获取用户名和密码
  3. 封装成一个 UsernamePasswordAuthenticationToken
  4. this.getAuthenticationManager().authenticate(authRequest); 调用AuthenticationManager 进行校验

再接着来看 authenticate 方法,我删了一部分

public Authentication authenticate(Authentication authentication)
    throws AuthenticationException{
    Class<?extends Authentication> toTest=authentication.getClass();
    AuthenticationException lastException=null;
    AuthenticationException parentException=null;
    Authentication result=null;
    Authentication parentResult=null;
    boolean debug=logger.isDebugEnabled();

    for(AuthenticationProvider provider:getProviders()){
        if(!provider.supports(toTest)){
            continue;
        }

        try{
            result=provider.authenticate(authentication);

            if(result!=null){
                copyDetails(authentication,result);
                break;
            }
        }
        catch(AccountStatusException|InternalAuthenticationServiceException e){

        }
    }

    if(result==null&&parent!=null){
        // Allow the parent to try.
        try{
            result=parentResult=parent.authenticate(authentication);
        }
        catch(ProviderNotFoundException e){

        }

        if(result!=null){
            if(eraseCredentialsAfterAuthentication
               &&(result instanceof CredentialsContainer)){
                ((CredentialsContainer)result).eraseCredentials();
            }

        }

    }

ProviderManager#authentication 在进行校验时,又将真正的校验工作委托给了 AuthenticationProvider 来进行。

  1. !provider.supports(toTest) 判断当前的AuthenticationProvider是否支持对 此类Authentication的校验(UsernamePasswordAuthenticationToken类型)
  2. 如果provider支持对此类型的Authentication进行校验,则调用provider.authenticate()进行校验
  3. 若是校验成功,则擦除密码返回 Authentication

2.2 实现思路

从上面那波源码的分析,整理下大致的思路。

  1. 前端发送登录请求,进入SpringSecurity过滤链
  2. 请求到达 UsernamePasswordAuthenticationFilter过滤器,执行其父类 AbstractAuthenticationProcessingFilter#doFilter
  3. doFilter方法中调用 UsernamePasswordAuthenticationFilter#attemptAuthentication
  4. attemptAuthentication方法 调用 ProviderManager#authentication
  5. ProviderManager 调用 AuthenticationProvider#authenticate 方法进行真正的校验
  6. 调用 DaoAuthenticationProvider 的父类 AbstractUserDetailsAuthenticationProvider 的方法 authenticate

要加验证码功能,此处我们可以考虑从第4.5.6入手,自定义一个 AuthenticationManager 加入到 ProviderManager 中, 替换原先的调用的 AbstractUserDetailsAuthenticationProvider#authenticate。 也不用完全重写校验规则,只需要在 原先 AbstractUserDetailsAuthenticationProvider#authenticate 的基础上,加一段对于验证码的判断就可以。

2.4 代码实现

先定义一个验证码,本次采用github上的一个项目来实现。


<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>
@Bean
Producer verifyCode(){
    Properties properties=new Properties();
    properties.setProperty("kaptcha.image.width","150");
    properties.setProperty("kaptcha.image.height","50");
    properties.setProperty("kaptcha.textproducer.char.string","0123456789");
    properties.setProperty("kaptcha.textproducer.char.length","4");
    Config config=new Config(properties);
    DefaultKaptcha defaultKaptcha=new DefaultKaptcha();
    defaultKaptcha.setConfig(config);
    return defaultKaptcha;
}

返回验证码图片的接口


@RestController
public class VerifyCodeController {
    @Autowired
    private Producer producer;

    @GetMapping("/vc.jpg")
    public void getVerifyCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("images/jpeg");
        String text = producer.createText();
        request.getSession().setAttribute("verify_code", text);

        BufferedImage image = producer.createImage(text);
        try (ServletOutputStream out = response.getOutputStream()) {
            ImageIO.write(image, "jpg", out);
        }
    }

    @GetMapping("/loginSuccess")
    public String loginSuccess() {
        return "登录成功O(∩_∩)O";
    }
}

自定义 AuthenticationProvider,加入验证码校验的逻辑,若是验证码校验通过,则继续原先的校验过程。

public class MyAuthenticationProvider extends DaoAuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String code = req.getParameter("code");
        String verify_code = (String) req.getSession().getAttribute("verify_code");
        if (code == null || verify_code == null || !code.equals(verify_code)) {
            throw new AuthenticationServiceException("验证码错误");
        }
        return super.authenticate(authentication);
    }
}

最后配置SpringSecurity,将自定义的AuthenticationProvdier加入到ProviderManager中


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
        return manager;
    }

    @Bean
    MyAuthenticationProvider myAuthenticationProvider() {
        MyAuthenticationProvider provider = new MyAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService());
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        ProviderManager providerManager = new ProviderManager(List.of(myAuthenticationProvider()));
        return providerManager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login.html", "/vc.jpg").permitAll()
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/loginSuccess", true)
                .failureHandler((request, response, exception) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter writer = response.getWriter();
                    writer.write(exception.getMessage());
                    writer.flush();
                    writer.close();
                })
                .permitAll()
                .and()
                .csrf().disable();
    }
}

感觉要实现邮箱登录、手机号登录 也可以通过类似的方法实现,留待后续尝试


保存/查看用户ip等信息

1. Authentication

Authentication 接口用来保存用户登录信息

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    Object getCredentials();

    Object getDetails();

    Object getPrincipal();

    boolean isAuthenticated();

    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

方法解释:

  1. getAuthorities 方法用于获取用户权限
  2. getCredentials 方法用于获取用户凭证,一般是密码
  3. getDetails 方法用于获取一些额外的信息,如登录IP、证书等
  4. getPrincipal 方法用于获取当前用户,可能是用户名,也可能是用户对象
  5. isAuthenticated 方法用于判断用户是否认证成功

本节的重点就是在 getDetails 这个方法。其源码注释如下

Stores additional details about the authentication request. These might be an IP address, certificate serial number etc.

该方法就是用于获取 存储的一些额外的,如ip、证书等信息。

在默认情况下,这里存储的是 IP地址 和 sessionId,接下来我们尝试让他存储更多信息。

2. 查看源码

想要getDetails(),必须先要setDetails()。 在 UsernamePasswordAuthenticationFilter#attemptAuthentication

        UsernamePasswordAuthenticationToken authRequest=new UsernamePasswordAuthenticationToken(
        username,password);

        setDetails(request,authRequest);

再来看下 setDetails 方法

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

UsernamePasswordAuthenticationToken 是 Authentication 的一个实现类,所以这里其实就是在这设置 details的值。

再进入 buildDetails 方法

public class WebAuthenticationDetailsSource implements
        AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {

    public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
        return new WebAuthenticationDetails(context);
    }
}

再进入 WebAuthenticationDetails 方法

    public WebAuthenticationDetails(HttpServletRequest request){
        this.remoteAddress=request.getRemoteAddr();

        HttpSession session=request.getSession(false);
        this.sessionId=(session!=null)?session.getId():null;
        }

可以看到默认是从请求中获取初 remoteAddress 和 sessionId 封装成一个 WebAuthenticationDetails 设置到 details 中


3. 实现思路

对上面的源码流程做个总结:

UsernamePasswordAuthenticationFilter 调用 WebAuthenticationDetailsSource 调用 WebAuthenticationDetails

所以,在我们还是用 UsernamePasswordAuthenticationFilter 的基础上,直接重写 WebAuthenticationDetailsSourceWebAuthenticationDetails即可

4. 代码实现

自定义 WebAuthenticationDetailsSource


@Component
public class MyWebAuthenticationDetailsSource extends WebAuthenticationDetailsSource {
    @Override
    public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
        return new MyWebAuthenticationDetails(context);
    }
}

自定义 WebAuthenticationDetails,此处可以添加存储一些自己需要的属性,父类 WebAuthenticationDetails 中以及有ip和sessionId了。

public class MyWebAuthenticationDetails extends WebAuthenticationDetails {

    private final Object something;

    public MyWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        something = request.getMethod();
    }

    public Object getSomething() {
        return something;
    }
}

在SecurityConfig配置自定义的 WebAuthenticationDetailsSource。 重点倒数第三行

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyWebAuthenticationDetailsSource myWebAuthenticationDetailsSource;

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

    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
        return manager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/login.html", "/login", "/details").permitAll()
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .successHandler((request, response, authentication) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    out.write("登录成功");
                    out.flush();
                    out.close();
                })
                .failureHandler((request, response, exception) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    out.write("登录失败/(ㄒoㄒ)/~~");
                    out.flush();
                    out.close();
                })
                .authenticationDetailsSource(myWebAuthenticationDetailsSource)
                .and()
                .csrf().disable();
    }
}

5. 测试

编写一个接口测试


@RestController
public class HelloController {
    @GetMapping("/details")
    public Map<String, Object> details(HttpServletRequest request) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();

        Map<String, Object> res = new HashMap<>(3);
        res.put("details", details);
        res.put("requestSessionId", request.getRequestedSessionId());
        res.put("session", request.getSession(false) == null ? null : request.getSession().getId());
        return res;
    }
}

启动项目,登录后访问 /details 接口 img17.png

一个用户只能在一个终端登录--基于内存

1.需求分析

在同一个系统中,我们可能只允许一个用户在一个终端上登录。

要实现一个用户不可以在两个终端上登录,有两种思路:

  1. 后登录的自动踢掉前面的登录,就像QQ
  2. 如果用户以及登录,则不允许后来者登录

在 SpringSecurity 中,两种实现都比较方便。

2.具体实现

2.1 踢掉前面登录的用户

想要用新的登录踢掉旧的登录,只需要将最大会话数设置为1即可,如下

@Override
protected void configure(HttpSecurity http)throws Exception{
        http.
        // 略
        .and().sessionManagement().maximumSessions(1);
        }

maximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。其他的配置与之前的一样,就删减掉了。

配置完成后,启动项目,使用 Chrome 和 Firefox 两个浏览器进行测试。

  1. Chrome 上登录,访问 /hello 接口,成功
  2. Firefox 上登录,访问 /hello 接口,成功
  3. 在 Chrome 上再次访问 /hello,失败,看到如下提示
This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).

表示 Chrome 上的这个 Session 以及过期。

2.2 禁止新的登录

如果相同的用户已经登录,不选择踢掉已登陆的用户,而是拒绝新的登录,则可以采取如下配置:

@Override
protected void configure(HttpSecurity http)throws Exception{
        http.
        // 略
        .and().sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true);
        }

还要再添加一个 bean

@Bean
HttpSessionEventPublisher httpSessionEventPublisher(){
        return new HttpSessionEventPublisher();
        }

配置完成之后,还是打开 Chrome 和 Firefox 浏览器:

  1. Chrome 上登录,访问 /hello 接口
  2. Firefox 上尝试登录,登录失败,提示如下
Maximum sessions of 1 for this principal exceeded

这里为什么要加上 HttpSessionEventPublisher 这个bean呢?

在Spring Security中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。用户从不同的浏览器登录后,都会有对应的session,当用户注销之后, session就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的。但是这个方法无法被 Spring 容器感知到,进而导致当用户注销登录之后,SpringSecurity 没有及时清理会话信息表,以为用户还在线,进而导致新用户无法登录进来。(具体效果可以不加这个bean试下)。

为了解决这个问题,我们提供一个 HttpSessionEventPublisher,这个类实现了 HttpSessionListener 接口,在该bean中,可以将 session 的创建以及销毁事件感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 SpringSecurity 感知到

,该类部分源码如下:

public void sessionCreated(HttpSessionEvent event){
    HttpSessionCreatedEvent e=new HttpSessionCreatedEvent(event.getSession());
    getContext(event.getSession().getServletContext()).publishEvent(e);
}

public void sessionDestroyed(HttpSessionEvent event){
    HttpSessionDestroyedEvent e=new HttpSessionDestroyedEvent(event.getSession());
    getContext(event.getSession().getServletContext()).publishEvent(e);
}

3. 实现原理

来分析下,只允许一个用户登录的功能在 SpringSecurity 中是如何实现的。

首先还是在过滤器这里,在前面几节的分析中我们知道,当一个用户登录时,会经过过滤器 UsernamePasswordAuthenticationFilter ,由于这个 Filter 没有重写父类的 doFilter 方法,所以 实际上会执行其父类 AbstractAuthenticationProcessingFilter#doFilter

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;
    }
    Authentication authResult;
    try{
        authResult=attemptAuthentication(request,response);
        if(authResult==null){
            return;
        }
        sessionStrategy.onAuthentication(authResult,request,response);
    }
    catch(InternalAuthenticationServiceException failed){
        unsuccessfulAuthentication(request,response,failed);
        return;
    }
    catch(AuthenticationException failed){
        unsuccessfulAuthentication(request,response,failed);
        return;
    }
    // Authentication success
    if(continueChainBeforeSuccessfulAuthentication){
        chain.doFilter(request,response);
    }
    successfulAuthentication(request,response,chain,authResult);

在执行完 attemptAuthentication ,完成校验之后,就会执行 sessionStrategy.onAuthentication,这个方法就是用来处理 Session 并发问题的。具体在:

public class ConcurrentSessionControlAuthenticationStrategy implements
        MessageSourceAware, SessionAuthenticationStrategy {
    public void onAuthentication(Authentication authentication,
                                 HttpServletRequest request, HttpServletResponse response) {

        final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
                authentication.getPrincipal(), false);

        int sessionCount = sessions.size();
        int allowedSessions = getMaximumSessionsForThisUser(authentication);

        if (sessionCount < allowedSessions) {
            // They haven't got too many login sessions running at present
            return;
        }

        if (allowedSessions == -1) {
            // We permit unlimited logins
            return;
        }

        if (sessionCount == allowedSessions) {
            HttpSession session = request.getSession(false);

            if (session != null) {
                // Only permit it though if this request is associated with one of the
                // already registered sessions
                for (SessionInformation si : sessions) {
                    if (si.getSessionId().equals(session.getId())) {
                        return;
                    }
                }
            }
            // If the session is null, a new one will be created by the parent class,
            // exceeding the allowed number
        }

        allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
    }

    protected void allowableSessionsExceeded(List<SessionInformation> sessions,
                                             int allowableSessions, SessionRegistry registry)
            throws SessionAuthenticationException {
        if (exceptionIfMaximumExceeded || (sessions == null)) {
            throw new SessionAuthenticationException(messages.getMessage(
                    "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
                    new Object[]{allowableSessions},
                    "Maximum sessions of {0} for this principal exceeded"));
        }

        // Determine least recently used sessions, and mark them for invalidation
        sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
        int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
        List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
        for (SessionInformation session : sessionsToBeExpired) {
            session.expireNow();
        }
    }
}

简单解释下这段代码的逻辑:

  1. 首先调用 sessionRegistry.getAllSessions(authentication.getPrincipal(), false); 获取当前用户的所有 session,该方法在调用的时候,传递两个参数,一个是当前用户的 authentication,另一个参数是 false,表示不包含已经过期的 session。

    (用户在登录成功时,会将用户的 sessionId 和 principal 还有当前时间 保存起来,具体在 SessionRegistryImpl#registerNewSession

  2. 计算当前用户已经有几个有效的 session,同时获取允许同时存在的 session 数量。

  3. 如果当前 session 数(sessionCount)小于 session并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,则表示对 session 数量不做任何限制。

  4. 如果当前 sessionCount ==allowedSessions,则判断下 当前session 是否不为null 且是已有session的一份子,如果是的话则不做处理。如果当前session为null,那么意味着将有一个新的session被创建出来,届时当前session数目将大于 allowedSessions

  5. 当前面的步骤,方法都没有被retun掉,则会进入策略判断方法 allowableSessionsExceeded

  6. allowableSessionExceeded 方法中,首先会判断 exceptionIfMaximumExceeded 属性,默认为false,会对session进行排序,使得多余的sessiom过期。如果设为true的话,就会直接抛出异常。这个属性就是我们在 SecurityConfig 中配置的 .maxSessionsPreventsLogin(true);的值

4. 小结

关于这一部分的内容

  1. 对于Spring的事件发布机制不了解

  2. 对于session的处理这一块有些混乱,HttpServletRequest 中的 session 是谁创建的? 什么时候创建的? 什么时候销毁的?

    猜测是tomcat处理的

留待后续学习解决

用户并发登录限制--基于数据库

上一节将的 SpringSecurity 如何踢掉前一个用户、或者禁止后面的用户登录,在基于内存用户的情况下,实现了想要的效果。

但是有一个问题就是这是基于内存用户,但是在实际的开发中,基本都是使用基于数据库的用户的。理论上来说切换到数据库,只需要配置数据库,重写一个 UserDetailsService 就可以,但是实际上这里有一个小坑需要注意下。

1. 环境准备

基本的配置还是和上一节 session-1 中的一样,使用 withjpa 这一节的配置来操作数据库。

具体代码查看 https://gitee.com/mqb98/spring-security-sample/tree/master/session-2

展示部分 SpringSecurity 配置

@Override
protected void configure(HttpSecurity http)throws Exception{
    http.authorizeRequests().anyRequest().authenticated();

    http.formLogin()
        .loginPage("/login.html")
        .loginProcessingUrl("/login") // 使用security默认的登录处理
        // 略...
        .permitAll()
        .and()
        .csrf().disable()
        // 略...
        .and().sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true);
}

2. 测试

如上,我们配置了只允许一个用户登录,且在一个用户登录成功之后禁止后一个用户登录。

使用 Chrome 和 Firefox 浏览器进行测试

  1. 使用 Chrome 登录,登录成功之后访问 /hello 接口。访问成功
  2. 使用 Firefox 尝试登录,登录成功,且访问 /hello 接口也成功。

这里就发现了问题,设置了禁止后一个用户登录情况下,使用Firefox登录应该都会失败,更何况访问 /hello 接口。

3. 问题分析

在上一节基于内存配置的 实现原理那里,提到过 当用户登录成功之后,会调用 SessionRegistryImpl#registerNewSession 注册新的session。我们的问题就是出在这里。

SpringSecurity 中通过 SessionRegistryImpl 类来实现对会话信息的统一管理,下面展示了此类部分源码

public class SessionRegistryImpl implements SessionRegistry,
        ApplicationListener<SessionDestroyedEvent> {

    /** <principal:Object,SessionIdSet> */
    private final ConcurrentMap<Object, Set<String>> principals;
    /** <sessionId:Object,SessionInformation> */
    private final Map<String, SessionInformation> sessionIds;

    public void registerNewSession(String sessionId, Object principal) {
        if (getSessionInformation(sessionId) != null) {
            removeSessionInformation(sessionId);
        }
        sessionIds.put(sessionId,
                new SessionInformation(principal, sessionId, new Date()));

        principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
            if (sessionsUsedByPrincipal == null) {
                sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
            }
            sessionsUsedByPrincipal.add(sessionId);
            return sessionsUsedByPrincipal;
        });
    }

    public void removeSessionInformation(String sessionId) {
        SessionInformation info = getSessionInformation(sessionId);
        if (info == null) {
            return;
        }
        sessionIds.remove(sessionId);
        principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
            sessionsUsedByPrincipal.remove(sessionId);
            if (sessionsUsedByPrincipal.isEmpty()) {
                sessionsUsedByPrincipal = null;
            }
            return sessionsUsedByPrincipal;
        });
    }

}
  1. 首先这个类定义了两个final 变量 principalssessionIds。principals 是一个 ConcurrentMap,其 key 是 principal,默认情况下其实就是 UsernamePasswordAuthenticationToken 中的 principal。

    具体的举例就是我们自定义的继承 UserDetails 的实体类

    其 value 则是一个 Set ,保存到就是这个用户对应的 sessionId

  2. 如果有新的session需要添加,则调用 registerNewSession 来添加

  3. 用户注销登录,sessionId 需要被移除,相关操作在 removeSessionInformation 方法中

这里可以发现一点,ConcurrentMap 的key 使用的是 principal 对象,用对象作为key,一定要重写 equals 和 hashCode 方法,否则第一次存完数据下一次就找不到了。

如果我们使用的是基于内存的用户,禁止重复登录是没有问题的,来简略看下内存用户类的定义,可见其也重写了 equals 和 hashCode

public class User implements UserDetails, CredentialsContainer {
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    @Override
    public boolean equals(Object rhs) {
        if (rhs instanceof User) {
            return username.equals(((User) rhs).username);
        }
        return false;
    }

    @Override
    public int hashCode() {
        return username.hashCode();
    }
}

也是因为这个原因,在我们使用数据库用户,自定义的用户类的时候就会出问题,要解决也很简单,只要重写这两个方法就好了。


@Entity(name = "t_user")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    // 略...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.username);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username);
    }
}

Security 安全功能

如果只是要单纯的实现认证和授权,其实自己写一个Filter拦截请求处理,比使用SpringSecurity简洁,不需要各种东西配置,也不需要去理解SpringSecurity的原理。看似系统有了 认证 和 授权 功能,系统就非常的安全,但是实际上不是这样的。

各种各样的 Web 攻击 每天都在发生,什么固定会话攻击,csrf攻击等等,如果不了解这些,那么写出来的系统肯定不能防御这些攻击。

使用 SpringSecurity 的好处就是,即使不了解这些攻击,也不用担心这些攻击,因为 SpringSecurity 已经被我们做好防御工作了。

1. HttpFirewall

在 SpringSecurity 中提供了一个 HttpFirewall 接口,看名字就是 Http防火墙,它可以自动处理一些非法请求。

HttpFirewall 目前一共有两个实现类: HttpFirewall.png

一个是严格模式的防火墙设置,还有一个是默认的防火墙设置。

DefaultHttpFirewall 的限制相对于 StrictHttpFirewall 要宽松一些,当然也意味着安全性不如 StrictHttpFirewall。

SpringSecurity 中默认使用的是 StricthttpFirewall

2. 防护措施

来看下 StrictHttpFirewall 都是从哪些方面来保护我们的应用的

2.1 只允许白名单中的方法

首先,对于请求的方法,只允许白名单中的方法,也就是说,不是所有的 Http 请求方法都可以执行。

截取部分 StrictHttpFirewall 源码

public class StrictHttpFirewall implements HttpFirewall {
    private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();

    private static Set<String> createDefaultAllowedHttpMethods() {
        Set<String> result = new HashSet<>();
        result.add(HttpMethod.DELETE.name());
        result.add(HttpMethod.GET.name());
        result.add(HttpMethod.HEAD.name());
        result.add(HttpMethod.OPTIONS.name());
        result.add(HttpMethod.PATCH.name());
        result.add(HttpMethod.POST.name());
        result.add(HttpMethod.PUT.name());
        return result;
    }

    private void rejectForbiddenHttpMethod(HttpServletRequest request) {
        if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
            return;
        }
        if (!this.allowedHttpMethods.contains(request.getMethod())) {
            throw new RequestRejectedException("The request was rejected because the HTTP method \"" +
                    request.getMethod() +
                    "\" was not included within the whitelist " +
                    this.allowedHttpMethods);
        }
    }
}

从这部分代码可以看出,Http 请求方法必须是 DELETE、GET、HEAD、OPTIONS、PATCH、POST、PUT 中的一个,请求才能发送成功,否则的话,会抛出 RequestRejectedException 异常。

# 发送一个 TRACE 请求
curl  -v -X TRACE http://localhost:8080/hello

# 返回 405 , method not allow
<!doctype html><html lang="en"><head><title>HTTP Status 405 – Method Not Allowed</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 405 – Method Not Allowed</h1></body></html>* Connection #0 to host localhost left intact

# java后台报错
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the HTTP method "TRACE" was not included within the whitelist [HEAD, DELETE, POST, GET, OPTIONS, PATCH, PUT]

如果想发送其他 Http 请求方法,如 TRACE,那就需要自己提供一个 StrictHttpFirewall 实例了,如下

    @Bean
    HttpFirewall httpFirewall(){
            StrictHttpFirewall strictHttpFirewall=new StrictHttpFirewall();
            strictHttpFirewall.setUnsafeAllowAnyHttpMethod(true);
            return strictHttpFirewall;
            }

setUnsafeAllowAnyHttpMethod(true); 表示不做 Http 请求方法校验,也就是什么方法都可以通过。

或者也可以通过 setAllowedHttpMethods 来重新定义可以通过的方法

2.2 请求地址不能有分号

如果使用了 SpringSecurity,请求地址是不能有 ; 的,如果请求地址有 ;,则会报错

org.springframework.security.web.firewall.RequestRejectedException:The request was rejected because the URL contained a potentially malicious String";"

「注意,在 URL 地址中,; 编码之后是 %3b 或者 %3B,所以地址中同样不能出现 %3b 或者 %3B

当在请求中出现 ; 一般情况下都是使用了 @MatrixVariable,如果要在 SpringBoot+ SpringSecurity 中使得 @MatrixVariable 生效,需要进行如下配置:

配置 SpringMvc

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        UrlPathHelper urlPathHelper = new UrlPathHelper();
        urlPathHelper.setRemoveSemicolonContent(false);
        configurer.setUrlPathHelper(urlPathHelper);
    }
}

配置 SpringSecurity

@Bean
HttpFirewall httpFirewall(){
    StrictHttpFirewall firewall=new StrictHttpFirewall();
    firewall.setAllowSemicolon(true);
    return firewall;
}

写一个简单的接口测试下


@RestController
public class HelloController {
    @RequestMapping(value = "/hello/{id}")
    public String hello(@PathVariable Integer id, @MatrixVariable String name) {
        System.out.println("id = " + id);
        System.out.println("name = " + name);
        return "hello";
    }
}

发送请求 http://localhost:8080/hello/123;name=spencer

控制台成功打印,请求返回 hello。测试通过

2.3 必须是标准化URL

请求地址必须是标准化 URL。

什么是表转化 URL? 这个主要从四个方面来判断,来看下源码

StrictHttpFirewall#isNormalized

private static boolean isNormalized(HttpServletRequest request){
    if(!isNormalized(request.getRequestURI())){
        return false;
    }
    if(!isNormalized(request.getContextPath())){
        return false;
    }
    if(!isNormalized(request.getServletPath())){
        return false;
    }
    if(!isNormalized(request.getPathInfo())){
        return false;
    }
    return true;
}
  • getRequestURI 就是获取请求协议之外的字符
  • getContextPath 是获取上下文路径,就是当前 project 的名字
  • getServletPath 就是获取这个请求的 servlet 路径
  • getPathInfo 就是除去 contextPath 和 servletPath 之外的部分

这四种路径中,都不能包含如下字符

  • ./
  • ../
  • /../
  • /.

2.4 必须是可打印的 ASCII 字符

如果请求地址中包含不可打印的 ASCII 字符,请求会被拒绝,来看下源码:

StrictHttpFirewall#containsOnlyPrintableAsciiCharacters

private static boolean containsOnlyPrintableAsciiCharacters(String uri){
    int length=uri.length();
    for(int i=0;i<length; i++){
        char c=uri.charAt(i);
        if(c< '\u0020'||c>'\u007e'){
            return false;
        }
    }

    return true;
}

2.5 双斜杠不被允许

如果请求地址中出现双斜杠,这个请求也将被拒绝,而且后台也会报错

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String "//"

双斜杠 // 使用 URL 地址编码后,是 %2F%2F,其中 f 大小写无所谓,所以请求地址中也不能出现 %2F%2F。

如果希望允许在请求之中出现 //,则可以进行如下配置:

@Bean
HttpFirewall httpFirewall(){
    StrictHttpFirewall firewall=new StrictHttpFirewall();
    firewall.setAllowUrlEncodedDoubleSlash(true);
    return firewall;
}

2.6 % 不被允许

如果自请求地址中出现 % 这个请求也将被拒绝。URL 编码后的 %%25,所以 %25 也将不能出现在 URL 地址中。

如果希望请求中允许出现 %,则可以进行如下配置:

@Bean
HttpFirewall httpFirewall(){
    StrictHttpFirewall firewall=new StrictHttpFirewall();
    firewall.setAllowUrlEncodedPercent(true);
    return firewall;
}

2.7 正反斜杠不被允许

额,这点我没有理解,也没有测试初具体的效果,百度了下也没有结果,就先暂时继续记下:

如果请求地址中包含斜杠编码后的字符 %2F 或者 %2f ,则请求将被拒绝。

如果请求地址中包含反斜杠 \ 或者反斜杠编码后的字符 %5C 或者 %5c ,则请求将被拒绝。

如果希望去掉如上两条限制,可以按照如下方式来配置:

@Bean
HttpFirewall httpFirewall() {
    StrictHttpFirewall firewall = new StrictHttpFirewall();
    firewall.setAllowBackSlash(true);
    firewall.setAllowUrlEncodedSlash(true);
    return firewall;
}

2.8 . 不被允许

这点我也不理解....也不知道如何测试,还是先抄下来吧。

如果请求地址中存在 . 编码之后的字符 %2e%2E,则请求将被拒绝。

如需支持,按照如下方式进行配置:

@Bean
HttpFirewall httpFirewall() {
    StrictHttpFirewall firewall = new StrictHttpFirewall();
    firewall.setAllowUrlEncodedPeriod(true);
    return firewall;
}

3. 小结

关于上面所说的这些限制,都是针对请求的 requestURI 进行的限制,而不是针对请求参数。例如请求是:

http://localhost:8080/hello?param=aa%2ebb,则不被上面所讲的限制。

具体这部内容可以查看源码:

public class StrictHttpFirewall implements HttpFirewall {
 @Override
 public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
  rejectForbiddenHttpMethod(request);
  rejectedBlacklistedUrls(request);
  rejectedUntrustedHosts(request);

  if (!isNormalized(request)) {
   throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
  }

  String requestUri = request.getRequestURI();
  if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
   throw new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters.");
  }
  return new FirewalledRequest(request) {
   @Override
   public void reset() {
   }
  };
 }
 private void rejectedBlacklistedUrls(HttpServletRequest request) {
  for (String forbidden : this.encodedUrlBlacklist) {
   if (encodedUrlContains(request, forbidden)) {
    throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
   }
  }
  for (String forbidden : this.decodedUrlBlacklist) {
   if (decodedUrlContains(request, forbidden)) {
    throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
   }
  }
 }
 private static boolean encodedUrlContains(HttpServletRequest request, String value) {
  if (valueContains(request.getContextPath(), value)) {
   return true;
  }
  return valueContains(request.getRequestURI(), value);
 }

 private static boolean decodedUrlContains(HttpServletRequest request, String value) {
  if (valueContains(request.getServletPath(), value)) {
   return true;
  }
  if (valueContains(request.getPathInfo(), value)) {
   return true;
  }
  return false;
 }
 private static boolean valueContains(String value, String contains) {
  return value != null && value.contains(contains);
 }
}

「注意:虽然我们可以手动修改 Spring Security 中的这些限制,但是不建议大家做任何修改,每一条限制都有它的原由,每放开一个限制,就会带来未知的安全风险。」

防御会话固定攻击

1. HttpSession

HttpSession 是一个服务端概念,服务端生成的 httpSession 都会有一个对应的 sessionid,这个 sessionid 会通过 cookie 传递给前端,前端以后发送请求的时候,就带上这个 sessionid 参数,服务端看到这个 sessionid 就会把前端请求和服务端的某一个 HttpSession 对应起来,形成 ”会话“ 的感觉。

浏览器关闭不会导致服务端的 HttpSession 失效,想让服务端的 HttpSession 失效,要么手动调用 HttpSession#invalide 方法;要么等 session 自动过期;要么重启服务端。

有的时候感觉浏览器关闭之后 session 就失效了,是因为浏览器关闭之后,保存在浏览器里边的 sessionid 就丢了(默认情况下),所以当浏览器再次访问服务端的的时候,服务端会给浏览器重新分配一个 sessionid,这个 sessionid 和之前的 HttpSession 对应不上,所以感觉 session 失效。

就以之前建立的几个Springboot项目为例,在服务端生成 sessionid 之后,会通过响应头来设置 cookie

image-20210405185155109

就是在服务端响应头中加了 Set-Cookie 字段,该字段指示浏览器更新 sessionid。同时其中还有一个 HttpOnly 属性,这个表示通过 JS 脚本无法读取到 Cookie 信息,这样能有效防止 XSS 攻击。

当浏览器下一次发送请求,如直接刷新以下,此次请求就会带上直接 set-cookie 中的 sessionid

image-20210405185832149

一般的话在登录之后,会新建一个session,此时又会通过 Set-Cookie 来修改 sessionid。

此后再发送请求,就不会携带之前那个 F7BB47... 了,还是改为携带 6F687... 这个sessionid了

image-20210405190344654

2. 固定会话攻击

会话固定攻击? 英文叫做 session fixation attack。

正常来说,只要你不关闭浏览器,并且服务端的 HttpSession 也没有过期,那么维系服务端和浏览器的 sessionid 是不会发生变化的,那么维系服务端和浏览器的 sessionid 是不会发生变化的,而会话固定攻击,则是利用这一机制,借助受害者用相同的会话的 ID 获取认证和授权,然后利用该会话 ID 劫持受害者的会话以成功冒充受害者,造成会话固定攻击。

一般来说,会话固定攻击的流程是这样,以淘宝:

  1. 攻击者自己可以正常访问淘宝网站,在访问的过程中,淘宝网站给攻击者分配了一个 sessionid。
  2. 攻击者利用自己拿到的 sessionid 构造一个淘宝网站的链接,并把该链接发送给受害者。
  3. 受害者使用该链接登录淘宝网站(该链接中含有 sessionid),登录成功后,一个合法的会话就成功建立。
  4. 攻击者利用手里的 sessionid 冒充受害者。

在这个过程中,如果淘宝网站支持 URL 重写,那么攻击还会变得更加容易。

URL 重写,就是用户如果在浏览器中禁用了 cookie,那么 sessionid 自然也用不了了,所以有的服务端就支持把 sessionid 放在请求地址中:

http://www.taobao.com;jsessionid=xxxx

如果服务端支持这种 URL 重写,那么对于攻击者来说,按照上面的攻击流程,构造一个这样的地址很简单。

不过这种请求地址在 SpringSecurity 种很少会见到。

3. 如何防御

这个问题的根源就在 sessionid 不变,如果用在未登录时拿到的是一个 sessionid,登录成功之后给用户重新换一个 sessionid,就可以防止会话固定攻击。

在使用了 SpringSecurity 情况下,其实是不用担心这个问题的,因为 SpringSecurity 中默认已经做了防御工作了。

SpringSecurity 中的防御工作主要体现在三个方面:

  1. 上一节 HttpFirewall 中会将 URI 中有 ; 的请求直接拒绝。
  2. 在响应头的 Set-Cookie 字段中有 HttpOnly 属性,这种方式避免了通过 XSS 攻击来获取 Cookie 中的会话信息进而达成会话固定攻击。
  3. 让 sessionid 变一下。

具体的配置如下:

image-20210405202832168

可以看到,这里有四个选项:

  1. migrateSession (默认) 表示在登录成功之后,创建一个新的会话,然后将旧的 session 中的信息复制到新的 session 中。这个默认选项。

  2. none 表示不做任何事情,继续使用旧的 sessionid。

  3. changeSessionId 表示 session 不变,但是会修改 sessionid。这实际上用到了 Servlet 容器提供的防御固定会话攻击。

  4. newSession 表示登录之后创建一个新的 session。

默认的 migrateSession ,在用户匿名访问的时候是一个 sessionid,当用户登录成功之后又是另一个 sessionid,这样可以有效避免固定会话攻击。

集群化部署-session共享

1. 集群会话方案

在传统的单服务架构中,一般来说,只有一个服务器,那么就不存在 session 共享问题,但是在分布式/集群部署项目中,Session 共享则是必须要面对的问题。先看个简单的架构图:

image-20210405210617527

这样的架构,会出现一些单体服务中不存在的问题,例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 session 中获取数据,发现没有之前的数据。

1.1 session共享

对于这一类问题的解决,目前比较主流的就是将各个服务器之间需要共享的数据,保存到一个公共的地方(主流是redis)

image-20210405212257032

当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。

这样的方案,可以由开发者手动实现,即手动往 Redis 中写数据,手动从 Redis 读数据,相当于使用一些 Redis 客户端来实现这样的功能,但是这样工作量较大。

一个简化的方案就是使用 Spring Session 来实现这一功能,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据同步到 Redis 中,或者自动从 Redis 读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成之后,具体的用法和使用一个普通的 session 一样。

1.2 session 拷贝

session 拷贝就是不利用 redis,直接在各个 Tomcat 之间进行 session 数据拷贝,但是这种方式效率有点低,Tomcat A、B、C 中任意一个的 session 发生了变化,都需要拷贝到其他 Tomcat 上,如果集群中的服务器数量特别多的话,这种方式不仅效率低,还会有很严重的延迟。

所以这种方案一般作为了解即可。

1.3 粘贴会话

所谓的粘滞会话就是将相同 IP 发送来的请求,通过 Nginx 路由到同一个 Tomcat 上去,这样就不用进行 session 共享与同步了。这是一个办法,但是在一些极端情况下,可能会导致负载失衡(因为大部分情况下,都是很多人用同一个公网 IP)。

所以,Session 共享就成为了这个问题目前主流的解决方案了。

2. Session 共享

2.1 基础环境搭建

首先的话就是创建一个Springboot项目

image-20210407134117581

其实就是导入如下依赖


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</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>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
    </dependency>
</dependencies>

此外再导入一个依赖用于redis连接


<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

然后在yml进行一些基础配置,主要就是配置了redis的连接 和 登录用户

spring:
  session:
    redis:
      flush-mode: on_save
      namespace: spring.example
    store-type: redis
    timeout: 1800

  redis:
    host: XXX.XXX.XXX.XX
    port: 6379
    database: 2
    lettuce:
      pool:
        max-active: 8

  security:
    user:
      name: user
      password: 123


其实基础的整合这样就好了,直接启动项目,访问 http://localhost:8080/login 进行登录,

登录成功之后,查看redis,可见session信息已经存入redis。

image-20210407140332813

2.2 自定义序列化方式

看上面的redis截图可知,这个value的默认序列化方式是使用 JDK序列化的方式。这种方式可读性差,而且占用空间大,所以下面准备替换序列化方式。


@Configuration
public class CustomRedisConfig {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Bean
    public RedisSerializer<Object> redisSerializer() {
        ObjectMapper om = objectMapper.copy();

        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.registerModule(new CoreJackson2Module());
        om.registerModule(new WebJackson2Module());
        om.registerModule(new WebServletJackson2Module());
        om.registerModule(new WebServerJackson2Module());

        SecurityJackson2Modules.enableDefaultTyping(om);
        return new GenericJackson2JsonRedisSerializer(om);
    }

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setDefaultSerializer(redisSerializer());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

@Configuration
@EnableRedisHttpSession
public class CustomRedisHttpSessionConfig {

    private final RedisSerializer<Object> redisSerializer;

    public CustomRedisHttpSessionConfig(RedisSerializer<Object> redisSerializer) {
        this.redisSerializer = redisSerializer;
    }

    /**
     * 修改 SpringSession redis 序列化方式
     * 注意这个beanName必须是 springSessionDefaultRedisSerializer
     * @return
     */
    @Bean
    public RedisSerializer springSessionDefaultRedisSerializer() {
        return redisSerializer;
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

配置完成后,再重启项目登录(注意先把之前在redis的session先删除),查看redis。

可见序列化方式已经被自定义修改了。

image-20210407141533197

2.3 使用数据库用户代替内存用户

首先的话先建个数据库,就三个表:t_usert_rolet_user_roles。就是用户表、角色表、关联表。

DROP TABLE IF EXISTS `t_role`;
CREATE TABLE `t_role`
(
    `id`      bigint(20) NOT NULL AUTO_INCREMENT,
    `name`    varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `name_zh` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_role
-- ----------------------------
INSERT INTO `t_role`
VALUES (1, 'ROLE_admin', '管理员');
INSERT INTO `t_role`
VALUES (2, 'ROLE_user', '普通用户');

-- ----------------------------
-- Table structure for t_user
-- ----------------------------
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user`
(
    `id`                      bigint(20) NOT NULL AUTO_INCREMENT,
    `account_non_expired`     bit(1) NOT NULL,
    `account_non_locked`      bit(1) NOT NULL,
    `credentials_non_expired` bit(1) NOT NULL,
    `enabled`                 bit(1) NOT NULL,
    `password`                varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `username`                varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_user
-- ----------------------------
INSERT INTO `t_user`
VALUES (1, b'1', b'1', b'1', b'1', '123', 'spencer');
INSERT INTO `t_user`
VALUES (2, b'1', b'1', b'1', b'1', '123', 'maqb');

-- ----------------------------
-- Table structure for t_user_roles
-- ----------------------------
DROP TABLE IF EXISTS `t_user_roles`;
CREATE TABLE `t_user_roles`
(
    `t_user_id` bigint(20) NOT NULL,
    `roles_id`  bigint(20) NOT NULL,
    INDEX       `FKj47yp3hhtsoajht9793tbdrp4`(`roles_id`) USING BTREE,
    INDEX       `FK7l00c7jb4804xlpmk1k26texy`(`t_user_id`) USING BTREE
) ENGINE = MyISAM CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Fixed;

-- ----------------------------
-- Records of t_user_roles
-- ----------------------------
INSERT INTO `t_user_roles`
VALUES (1, 1);
INSERT INTO `t_user_roles`
VALUES (2, 2);

SET
FOREIGN_KEY_CHECKS = 1;

然后创建 UserRole 两个实体类。

public class User implements UserDetails {

    private Long id;
    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;
    private List<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (getRoles() == null) {
            return null;
        }
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : getRoles()) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return authorities;
    }

    // 略...
}
public class Role implements Serializable {

    private Long id;
    private String name;
    private String nameZh;

    // getter and setter
}

我此处使用的是mybatis-plus,后面就是加入依赖、写mapper、配置SpringSecurity等等...就不贴代码了。有需要的直接看gitee上的代码。


@Service
public class UserService implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.selectOneUserWithRoleByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        return user;
    }
}

下面重点来了:

在配置完数据库用户和SpringSecurity之后,启动项目,登录,都没有问题,而且数据也进入了 redis 中

image-20210407145028967

但是在登录成功后,随便访问一个接口。比如 /hello,请求就会出错,而且控制台报错如下:

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: The class with com.mqb.model.User and name of com.mqb.model.User is not whitelisted. 

If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details 

大体就是说反序列化失败,User类不在白名单中。

这个问题我百度、Google了好久,都没有找到类似的回答,经过尝试,如果使用默认的JDK序列化方式是不会出问题的,在自定义序列化方式后就出问题了。

后面还是想起来看下 SpringSecurity 对于内存用户类 User 是如何处理的,我直接模仿它就好了。

CoreJackson2Module 中,可以看到 SpringSecurity对于 内存用户类 User 的处理

    @Override
public void setupModule(SetupContext context){
        SecurityJackson2Modules.enableDefaultTyping(context.getOwner());
        // 略...
        context.setMixInAnnotations(SimpleGrantedAuthority.class,SimpleGrantedAuthorityMixin.class);
        context.setMixInAnnotations(User.class,UserMixin.class);
        // 略...
        }

点击 UserMixin.class 查看此类


@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
@JsonDeserialize(using = UserDeserializer.class)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
        isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
abstract class UserMixin {
}

发现此处还自定义了一个 UserDeserializer 用于 User 的反序列化。

我们就直接照猫画虎的也写如下几个类

UserMixinUserDeserializerRoleMixinRoleDeserializerCustomModule

然后将 CustomModule 注册到ObjectMapper中


@Configuration
public class CustomRedisConfig {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Bean
    public RedisSerializer<Object> redisSerializer() {
        ObjectMapper om = objectMapper.copy();

        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.registerModule(new CoreJackson2Module());
        om.registerModule(new WebJackson2Module());
        om.registerModule(new WebServletJackson2Module());
        om.registerModule(new WebServerJackson2Module());

        om.registerModule(new CustomModule());

        SecurityJackson2Modules.enableDefaultTyping(om);
        return new GenericJackson2JsonRedisSerializer(om);
    }

    // 略...
}

具体代码查看:

https://gitee.com/mqb98/spring-security-sample/tree/master/session-4/src/main/java/com/mqb/config/mixin

注意:如果使用的是 JPA,在我们模仿完SpringSecurity 提供了 Mixin 类之后,还是会出错,如下:

org.hibernate.collection.internal.PersistentBag and name of org.hibernate.collection.internal.PersistentBag is not whitelisted....

看起来是和最初的错一样,只不过由原先的自定义 User 类变成了 hibernate 的类,这个错我就没有继续往下自定义 Mixin 来尝试解决了,我直接使用 Mybatis-plus 进行处理...

2.4 Session共享测试

将项目打个包,分别以 8080 和 8081 端口启动 :java -jar xxx.jar --server.port=8080

  1. 访问 http://localhost:8080/login.html 进行登录
  2. 8080登录成功,访问 http://localhost:8080/hello ,成功
  3. 直接访问 http://localhost:8081/hello ,成功,不用登录可以直接访问

说明session共享成功,因为 8081 请求 /hello 时 带上了 8080 登录成功的Session的id,所以可以从redis中获取到认证用户。

注意此处进行打包测试比较好,直接在idea中重新配个端口启动两个项目,测试会出现问题。

2.5 控制用户并发登录

控制一个用户只能在一个终端登录


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    FindByIndexNameSessionRepository sessionRepository;

    @Bean
    SpringSessionBackedSessionRegistry sessionRegistry() {
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().maximumSessions(1)
                .maxSessionsPreventsLogin(true).sessionRegistry(sessionRegistry());
    }

防御CSRF攻击

CSRF 就是跨域请求伪造,英文全称是 Cross Site Request Forgery

1.csrf 原理

先来理解下 csrf 的攻击流程:

image-20210407200647748

简单的描述下:

  1. 假设用户打开了招商银行网上银行网站,并且登录。
  2. 登录成功后,网上银行会返回 Cookie 给前端,浏览器将 Cookie 保存下来。
  3. 用户在没有登出网上银行的情况下,在浏览器里边打开了一个新的选项卡,然后去访问了危险网站。
  4. 这个危险网站上有一个超链接,超链接的地址指向网上银行。
  5. 用户在危险网站点击了这个链接,由于这个超链接会自动携带上浏览器中保存的 Cookie,,所以用户在危险网站不知不觉就访问了网上银行,进而可能给自己造成损失。

在大致理解了 csrf 攻击的意思之后,我们写个简单的例子。

2. csrf 实践

2.1 csrf-1 模拟银行

创建一个名为 csrf-1 的项目,引入 security 和 web 的依赖。以这个项目作为上面所说的招商银行网上银行。

直接在yaml中配置 SpringSecurity 用户

spring:
  security:
    user:
      name: spencer
      password: 123

写两个简单的测试接口,这个 /transfer 接口简单的模拟下是一个转账接口。


@RestController
public class HelloController {
    @PostMapping("/transfer")
    public void transferMoney(String name, Double money) {
        System.out.println("给【" + name + "】转了【" + money + "】元");
    }

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

由于 SpringSecurity 默认是自动进行 CSRF 攻击防御的,所以我们需要在配置中将之关闭。


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
    }
}

到此 csrf-1 项目配置完成,直接启动项目

2.2 csrf-2 模拟危险网站

创建一个 csrf-2 项目模拟危险网站,这个项目只要引入一个 web 依赖就可以。

指定启动端口为 8081

server:
  port: 8081

然后在资源目录下创建一个 hello.html


<body>
<h3>危险网站</h3>
<form action="http://localhost:8080/transfer" method="post">
    <input type="hidden" value="huff" name="name">
    <input type="hidden" value="10000" name="money">
    <input type="submit" value="点击查看美女图片">
</form>
</body>

这里有一个超链接,超链接的文本就是 点击查看美女图片,当你点击超链接之后,会自动请求 http://localhost:8080/transfer 接口,同时还携带了两个隐藏域参数。

配置完成之后,启动 csrf-2

2.3 测试

  1. 启动完成 csrf-1 和 csrf-2

  2. 访问 http://localhost:8080/login,进行登录

  3. 登录成功后,在浏览器中再开一个标签页,访问 http://localhost:80801/hello.html

    image-20210407203733581

  4. 点击【点击查看美女图片】

  5. 发现钱被转出去了,控制台输出如下

    给【huff】转了【10000.0】元
    

3. csrf 防御

先来说下防御思路。

CSRF 防御,一个核心思路就是在前端请求中,添加一个随机数。

因为在 CSRF 攻击中,黑客网站其实是不知道用户的 Cookie 具体是什么,他是让用户自己发请求到网上银行这个网站的,因为这个过程会自动携带上 Cookie 中的信息。

所以我们的防御思路是这样的:用户在访问网上银行的时候,除了携带 Cookie 中的信息之外,还需要携带一个随机数,如果用户没有携带该随机数,则网上银行会拒绝该请求。

SpringSecurity 中对此提供了很好的支持。

3.1 默认方案

SpringSecurity 中默认实际上就提供了 csrf 防御,但是需要开发者做的事情比较多。

首先来创建一个项目,引入 Security、Thymeleaf、web 依赖

在配置文件中配置用户名/密码

spring:
  security:
    user:
      name: spencer
      password: 123

提供一个简单的测试接口


@Controller
public class HelloController {
    @PostMapping("/hello")
    @ResponseBody
    public String hello() {
        return "hello";
    }

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

注意,这个测试接口是一个 POST 请求,因为默认情况下,GET、HEAD、TRACE 以及 OPTIONS 是不需要验证 CSRF 攻击的。

第二个接口是用于请求页面的。

在 static/template 下创建一个 thymeleaf 模板 hello.html


<body>
<form action="/hello" method="post">
    <input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
    <input type="submit" value="hello">
</form>
</body>

启动项目登录,使用 SpringSecurity 默认的登录界面登录,默认就会带上 csrf

image-20210407210551716

然后再访问我们的 POST 测试接口,可见也会携带上 csrf。(因为我们在表单传了这个参数,如果不加这个参数,接口会403)

image-20210407210630809

3.2 前后端分离方案

上面所展示的是前后端不分离的方法,下面讲下前后端分离的用法。

区别就是前后端分离,就不把 _csrf 放在 Model 中返回给前端了,而是放在 Cookie 中。如下:


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }
}

重启项目,发现多了一段这个

image-20210407211539356

接下来修改配置,自定义登录页

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="js/jquery.min.js"></script>
    <script src="js/jquery.cookie.js"></script>
</head>
<body>
<div>
    <input type="text" id="username">
    <input type="password" id="password">
    <input type="button" value="登录" id="loginBtn">
</div>
<script>
    $("#loginBtn").click(function () {
        let _csrf = $.cookie('XSRF-TOKEN');
        $.post('/login.html', {
            username: $("#username").val(),
            password: $("#password").val(),
            _csrf: _csrf
        }, function (data) {
            alert(data);
        })
    })
</script>
</body>
</html>
  1. 首先引入 jquery 和 jquery.cookie ,方便我们一会操作 Cookie。
  2. 定义三个 input,前两个是用户名和密码,第三个是登录按钮。
  3. 点击登录按钮之后,我们先从 Cookie 中提取出 XSRF-TOKEN,这也就是我们要上传的 csrf 参数。
  4. 通过一个 POST 请求执行登录操作,注意携带上 _csrf 参数。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/js/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .successHandler((req,resp,authentication)->{
                    resp.getWriter().write("success");
                })
                .permitAll()
                .and()
                .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }
}

image-20210407211847813

CSRF 源码分析

累了,暂时略过...

https://mp.weixin.qq.com/s?__biz=MzI1NDY0MTkzNQ==&mid=2247488680&idx=1&sn=dbadb73a552619aa42d10f4ac13ece6d&chksm=e9c346c8deb4cfde09b9e7967090e032b4a3c79e0c2cbc52dba3e2598b80c20d4e4a3183bc7e&scene=178&cur_album_id=1319828555819286528#rd

密码加密

密码加密我们一般会用到散列函数,又称散列算法、哈希函数,这是一种从任何数据中创建数字“指纹”的方法。

散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。

我们常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm)。

但是仅仅使用散列函数还不够,单纯的只使用散列函数,如果两个用户密码明文相同,生成的密文也会相同,这样就增加的密码泄漏的风险。

为了增加密码的安全性,一般在密码加密过程中还需要加盐,所谓的盐可以是一个随机数也可以是用户名,加盐之后,即使密码明文相同的用户生成的密码密文也不相同,这可以极大的提高密码的安全性。

传统的加盐方式需要在数据库中有专门的字段来记录盐值,这个字段可能是用户名字段(因为用户名唯一),也可能是一个专门记录盐值的字段,这样的配置比较繁琐。

Spring Security 提供了多种密码加密方案,官方推荐使用 BCryptPasswordEncoder,BCryptPasswordEncoder 使用 BCrypt 强哈希函数,开发者在使用时可以选择提供 strength 和 SecureRandom 实例。strength 越大,密钥的迭代次数越多,密钥迭代次数为 2^strength。strength 取值在 4~31 之间,默认为 10。

不同于 Shiro 中需要自己处理密码加盐,在 Spring Security 中,BCryptPasswordEncoder 就自带了盐,处理起来非常方便。

1. codec 加密

commons-codec 是一个 Apache 上的开源项目,用它可以方便的实现密码加密。

首先引入依赖:


<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
</dependency>

然后自定义一个 PasswordEncoder 并且注入到容器中


@Component
public class MyPasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence rawPassword) {
        return DigestUtils.md5Hex(rawPassword.toString());
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(DigestUtils.md5Hex(rawPassword.toString()));
    }
}

在 SpringSecurity 中,PasswordEncoder 专门用于处理密码的加密与比对工作。我们自定义 PasswordEncoder 至少需要实现两个方法。

  1. encode 方法表示对密码加密,参数 rawPassword 就是明文密码,返回的是加密后的密文。
  2. matches 方法表示对密码进行比对,参数 rawPassword 是用户传入的密码,encodedPassword 则是加密后的密码(从数据库获得)

将 MyPasswordEncoder 注册进容器之后,在用户登录时,会自动调用 matches 方法进行比对。

注意使用了密码加密之后,在用户注册的时候,就需要调用 encode 方法对密码进行加密,再存入数据库。

2. BCryptPasswordEncoder 加密

自定义 PasswordEncoder 麻烦的地方在于加盐的时候,一般来说要么给一个字段保存盐值,要么使用用户名作为盐(用户名唯一)。但是这些都需要自己手动进行。

而 SpringSecurity 中提供了 BCryptPasswordEncoder,使得密码加密加盐变得很简单,只需要提供 BCryptPasswordEncoder 这个实例就好了。

@Bean
PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder(10);
        }

创建 BCryptPasswordEncoder 时传入的参数 10 就是 strength,即密钥的迭代次数(也可以不配置,默认为 10)。同时,配置的内存用户的密码也不再是 123 了,如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("spencer")
                .password("$2a$10$qF6JeBrhLsXmB18Mh4VarOsWTkAS4dWIapvXdWmzRcDXQWmMk/Ahy")
                .roles("admin")
                .and()
                .withUser("maqb")
                .password("$2a$10$miKjLcPXLeWNS8UJEzjJQO/ecFS2nBXHIWgXgInOHPYYea4bt1PKa")
                .roles("user");
    }
}

可以看到 spencer 和 maqb 两个用户的密文密码不一样,但是他们的明文密码都是 123

3. 源码浅析

略...

CAS单点登录

在 Spring Cloud Security 中使用 OAuth2 + JWT 或者使用 @EnableOAuth2Sso 注解会方便很多。

但是还是得学习下 CAS + SpringSecurity 来实现单点登录

1. 什么是 CAS

CAS 全称叫做中央认证服务,英文是 Central Authentication Service

这是由耶鲁大学发起的一个开源项目,目的是帮助 Web 应用系统构建一种可靠的单点登录解决方案

1.1 CAS 架构

CAS 分为两部分:

  • 一个是 CAS Server,这时单点验证服务,作用类似于 OAuth2 + JWT 方案中的 授权服务器,用来校验用户名/密码等。一般来说都是独立部署的。
  • 另一个则是 CAS Client,相当于一个个的(微)服务

我们来看 CAS 的官方给出的一个架构图:

图片

可以看到,用户访问的是 CAS Clients,CAS Clients 和 CAS Server 之间的通信支持多种协议,CAS Server 处理具体的认证事宜,CAS Server 对数据源的支持也非常多样化。

CAS Client 支持的平台有:

  • Apache httpd Server (mod_auth_cas module)
  • Java (Java CAS Client)
  • .NET (.NET CAS Client)
  • PHP (phpCAS)
  • Perl (PerlCAS)
  • Python (pycas)
  • Ruby (rubycas-client)

CAS 支持的通信协议有:

  • CAS (versions 1, 2, and 3)
  • SAML 1.1 and 2
  • OpenID Connect
  • OpenID
  • OAuth 2.0
  • WS Federation

从图中也可以看出 CAS 支持多种不同的认证机制,具体有:

  • JAAS
  • LDAP
  • RDBMS
  • SPNEGO
  • ...

1.2 三个概念

在 CAS 的整个登录过程中,有三个重要的概念

  1. TGT:TGT 全称叫做 Ticket Granting Ticket,这个相当于我们平时所见到的 HttpSession 的作用,用户登录成功后,用户的基本信息,如用户名、登录有效期等信息,都将存储在此。
  2. TGC:TGC 全称叫做 Ticket Granting Cookie,TGC 以 Cookie 的形式保存在浏览器中,根据 TGC 可以帮助用户找到对应的 TGT,所以这个 TGC 有点类似与会话 ID。
  3. ST:ST 全称是 Service Ticket,这是 CAS Sever 通过 TGT 给用户发放的一张票据,用户在访问其他服务时,发现没有 Cookie 或者 ST ,那么就会 302 到 CAS Server 获取 ST,然后会携带着 ST 302 回来,CAS Client 则通过 ST 去 CAS Server 上获取用户的登录状态。

2. CAS 登录流程

图片

这张图其实画的比较清楚了,用文字解释下:

术语:应用1、应用2 分别表示被保护的应用。

  1. 用户通过浏览器访问应用1,应用1 发现用户没有登录,于是返回 302,并且携带上一个 service 参数,让用户去 CAS Server 上登录。
  2. 浏览器自动重定向到 CAS Server 上,CAS Server 获取用户 Cookie 中携带的 TGC,去校验用户是否已经登录,如果已经登录,则完成身份校验(此时 CAS Server 可以根据用户的 TGC 找到 TGT,进而获取用户的信息);如果未登录,则重定向到 CAS Server 的登录页面,用户输入用户名/密码,CAS Server 会生成 TGT,并且根据 TGT 签发一个 ST,再将 TGC 放在用户的 Cookie 中,完成身份校验。
  3. CAS Server 完成身份校验之后,会将 ST 拼接在 service 中,返回 302,浏览器将首先将 TGC 存在 Cookie 中,然后根据 302 的指示,携带上 ST 重定向到应用1。
  4. 应用1 收到浏览器传来的 ST 之后,拿去 CAS Server 上校验,去判断用户的登录状态,如果用户登录合法,CAS Server 就会返回用户信息给 应用1。
  5. 浏览器再去访问应用2,应用2 发现用户未登录,重定向到 CAS Server。
  6. CAS Server 发现此时用户实际上已经登录了,于是又重定向回应用2,同时携带上 ST。
  7. 应用2 拿着 ST 去 CAS Server 上校验,获取用户的登录信息。

在整个登录过程中,浏览器分别和 CAS Server、应用1、应用2 建立了会话,其中,和 CAS Server 建立的会话称之为全局会话,和应用1、应用2 建立的会话称之为局部会话;一旦局部会话成功建立,以后用户再去访问应用1、应用2 就不会经过 CAS Server 了。

3. CAS Server 搭建

3.1 版本选择

目前最新的 CAS Server 是 6.x,这个是基于 gradle 来构建的,但是不熟悉 gradle,因此这里我选择 5.3 的版本,该版本基于大家熟悉的 maven 来构建。

官方为我们提供了构建 CAS Server 的模版,地址是:https://github.com/apereo/cas-overlay-template。

我们在分支中选择 5.3 版本下载:

图片

将项目作为 maven 项目导入,可以直接右键 pom.xml 点击 add as maven project。

成功导入后会生成一个 overlay 目录

后面自己手动给他加上 src/main/java 和 resources。

将 overlays/org.apereo.cas.cas-server-webapp-tomcat-5.3.14/WEB-INF/classes/application.properties 拷贝到 resources 目录下。

3.2 HTTPS 证书

CAS Server 从版本 4 开始,要使用 HTTPS 通信,所以我们得提前准备 HTTPS 证书。公司里的项目的话,需要购买 HTTPS 证书,自己玩的话也可以从云服务厂商那里申请到免费的 HTTPS 证书。

现在我们在本地测试,直接利用 JDK 自带的 keytool 工具,自己生成一个 HTTPS 证书即可。

生成命令如下:

keytool -genkey -alias casserver -keyalg RSA -keystore ./keystore

在 resource执行指令。

证书在执行的时候,需要给一个密钥库口令,这个大家随意给出即可,但是给出了多少要自己记着。另外,在 What is your first and last name? 选项中,需要填入 CAS Server 的域名,这点切记

image-20210513151652661

执行完成之后,会在 resources 目录中多出一个 keystore 文件。

如此之后,我们的 HTTPS 证书就有了,虽然这个证书不被各大厂商认可,但是自己做练习够用了。

3.3 配置并启动

通过上面的步骤,此时 resources 目录下有了两个文件

image-20210513151741179

接下来修改 application.properties,主要是配置下 keystore 的位置和密钥

server.ssl.key-store=classpath:keystore
server.ssl.key-store-password=111111
server.ssl.key-password=111111

配置完成之后,去 cas sever 的根路径下执行

build.cmd bootrun		// windows 环境
./build.sh bootrun		// linux 环境

第一次启动比较慢,要下载很多东西

注意:如果环境是 java8 以上,可能会报错,有包找不到

Caused by: java.lang.ClassNotFoundException: javax.xml.bind.annotation.XmlElement

这是因为 8以上引入了模块的概念,javaEE 的包不再和 javase 混在一起。若是出现这个错,可以在pom中加上以下依赖

<dependencies>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
    </dependencies>

在最后如果打印了如下结果,则说明启动成功。中间可能会报两个错,是 log4j2.xml 文件找不到,暂时不用管他。

image-20210513133048100

这时我们就可以输入 https://localhost:8443/cas/login 访问登录页。如果想要使用域名 cas.maqb.org 的话就得配置下 hosts 才行了。

image-20210513155529676

默认的用户名是 casuser,密码是 Mellon,输入用户名密码就可以登录了。

image-20210513155643902

默认的用户名和密码也可以在 application.properties 中配置,修改在最后一行

cas.authn.accept.users=casuser::Mellon

4. CAS Client 搭建

4.1 准备工作

4.1.1 服务记录

某一个 Client 需要接入 CAS Server 进行验证,则该 Client 必须提前在 CAS Server 上配置其信息。

这个信息既可以动态添加,也可以通过 JSON 来配置,后面松哥会教搭建如何动态添加,这里方便起见,我们还是通过 JSON 来进行配置。

具体配置方式如下,在 CAS Server 中创建如下目录:

src/main/resources/services

在该目录下创建一个名为 client1-99.json 的文件,client1 表示要接入的 client 的名字,99 表示要接入的 client 的 id,json 文件内容如下(这个配置可以参考官方给出的模版:overlays/org.apereo.cas.cas-server-webapp-tomcat-5.3.14/WEB-INF/classes/services/Apereo-10000002.json):

{
  "@class": "org.apereo.cas.services.RegexRegisteredService",
  "serviceId": "^(https|http)://.*",
  "name": "client1",
  "id": 99,
  "description": "应用1 的定义信息",
  "evaluationOrder": 1
}

这段 JSON 配置含义如下:

  1. @calss 指定注册服务类,这个是固定的org.apereo.cas.services.RegexRegisteredService。
  2. serviceId 则通过正则表达式用来匹配具体的请求。
  3. name 是接入的 client 的名称。
  4. id 是接入的 client 的 id。
  5. description 是接入的 client 的描述信息。
  6. evaluationOrder 则指定了执行的优先级。

接下来再在 src/main/resources/application.properties 文件中配置刚刚 json 的信息,如下:

cas.serviceRegistry.json.location=classpath:/services
cas.serviceRegistry.initFromJson=true

这里有两行配置:

  1. 指定配置 JSON 文件的位置。
  2. 开启 JSON 识别。

OK,配置完成后,重启 CAS Server。

CAS Server 启动成功后,我们在控制台看到如下日志,表示 JSON 配置已经加载成功了:

image-20210513171134849

JDK证书

第二个要提前准备的东西就是 JDK 证书。

在实际开发中,这一步可以忽略,但是因为我们现在用的自己生成的 SSL 证书,所以我们要将自己生成的证书导入到 JDK 中,否则在使用 Spring Security 接入 CAS 单点登录时,会抛出如下错误:

图片

将 SSL 证书导入 JDK 中的命令其实也很简单,两个步骤,第一个导出 .cer 文件,第二步,导入 JDK,命令如下:

在之前的 CAS Server 的证书目录下,执行以下命令导出一个 SSL 证书。或者在 -keystore 后面指定我们之前 生成的keystore 所在的目录。

keytool -export -alias casserver -file cassser.cer -keystore ./keystore -rfc

按照如上指令,会生成一个 cassser.cer 文件。把这个文件放到jdk密钥库

密钥库的位置在 JDK 目录下的 /lib/security/cacerts,(在 JDK9 之前,位置在 jre/lib/security/cacerts)。

然后再调用 import 指令,注意这里的密钥库指令是 changeit,不是我们之前自定义的

image-20210514113210178

我们在本地测试一定要导入证书到 JDK 证书库中,否则后面的测试会出现上图中的错误,证书导入 JDK 证书库之后,要确保之后的开发中,使用的是本地的 JDK。

注意,JDK 证书导入之后,CASServer 需要重启一下。

4.2 开发 Client

在使用 Spring Security 开发 CAS Client 之前,有一个基本问题需要先捋清楚:用户登录是在 CAS Server 上登录,所以 Spring Security 中虽然依旧存在用户的概念,但是对于用户的处理逻辑会和前面的有所不同。(之前是通过用户名去数据查出用户来比对密码判断登录是否成功。现在不需要判断,获得用户名的时候就已经在 CAS Server 登录成功了,现在只是用来获取详细的登录用户信息)

首先我们来创建一个普通的 Spring Boot 项目,加入 Web 依赖 和 Spring Security 依赖,如下:

图片

项目创建成功后,我们再来手动加入 cas 依赖:

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

接下来,在 application.properties 中配置 CAS Server 和 CAS Client 的请求地址信息:

cas:
  server:
    prefix: https://cas.maqb.org:8443/cas
    login: ${cas.server.prefix}/login
    logout: ${cas.server.prefix}/logout

  client:
    prefix: http://client1.cas.maqb.org:8080
    login: ${cas.client.prefix}/login/cas
    logoutRelative: /logout/cas
    logout: ${cas.client.prefix}${cas.client.logoutRelative}

这些配置都是自定义配置,所以配置的 key 可以自己随意定义。至于配置的含义都好理解,分别配置了 CAS Server 和 CAS Client 的登录和注销地址。

配置好之后,我们需要将这些配置注入到实体类中使用

这里我创建两个类分别用来接收 CAS Server 和 CAS Client 的配置文件:

@ConfigurationProperties(prefix = "cas.server")
public class CASServerProperties {
    private String prefix;
    private String login;
    private String logout;
    //省略 getter/setter
}
@ConfigurationProperties(prefix = "cas.client")
public class CASClientProperties {
    private String prefix;
    private String login;
    private String logoutRelative;
    private String logout;
    //省略 getter/setter
}

另外记得在启动类上面添加 @ConfigurationPropertiesScan 注解来扫描这两个配置类:

@SpringBootApplication
@EnableConfigurationProperties({CasServerProperties.class, CasClientProperties.class})
public class Client1Application {

    public static void main(String[] args) {
        SpringApplication.run(Client1Application.class, args);
    }
}

这里配置完成后,我们一会将在配置文件中来使用。

接下来创建 CAS 的配置文件,略长:

@Configuration
public class CasSecurityConfig {
    @Autowired
    CASClientProperties casClientProperties;
    @Autowired
    CASServerProperties casServerProperties;
    @Autowired
    UserDetailsService userDetailService;

    @Bean
    ServiceProperties serviceProperties() {
        ServiceProperties serviceProperties = new ServiceProperties();
        serviceProperties.setService(casClientProperties.getLogin());
        return serviceProperties;
    }

    @Bean
    @Primary
    AuthenticationEntryPoint authenticationEntryPoint() {
        CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
        entryPoint.setLoginUrl(casServerProperties.getLogin());
        entryPoint.setServiceProperties(serviceProperties());
        return entryPoint;
    }

    @Bean
    TicketValidator ticketValidator() {
        return new Cas20ProxyTicketValidator(casServerProperties.getPrefix());
    }

    @Bean
    CasAuthenticationProvider casAuthenticationProvider() {
        CasAuthenticationProvider provider = new CasAuthenticationProvider();
        provider.setServiceProperties(serviceProperties());
        provider.setTicketValidator(ticketValidator());
        provider.setUserDetailsService(userDetailService);
        provider.setKey("javaboy");
        return provider;
    }

    @Bean
    CasAuthenticationFilter casAuthenticationFilter(AuthenticationProvider authenticationProvider) {
        CasAuthenticationFilter filter = new CasAuthenticationFilter();
        filter.setServiceProperties(serviceProperties());
        filter.setAuthenticationManager(new ProviderManager(authenticationProvider));
        return filter;
    }

    @Bean
    SingleSignOutFilter singleSignOutFilter() {
        SingleSignOutFilter sign = new SingleSignOutFilter();
        sign.setIgnoreInitConfiguration(true);
        return sign;
    }
    @Bean
    LogoutFilter logoutFilter() {
        LogoutFilter filter = new LogoutFilter(casServerProperties.getLogout(), new SecurityContextLogoutHandler());
        filter.setFilterProcessesUrl(casClientProperties.getLogoutRelative());
        return filter;
    }
}

这个配置文件略长,但是并不难,我来和大家挨个解释:

  1. 首先一进来注入三个对象,这三个中,有两个是我们前面写的配置类的实例,另外一个则是 UserDetailsService,关于 UserDetailsService,其实就是 SpringSecurity 的各种数据源的抽象封装。在后面给出 UserDetailsService 的实现。
  2. 接下来配置 ServiceProperties,ServiceProperties 中主要配置一下 Client 的登录地址即可,这个地址就是在 CAS Server 上登录成功后,重定向的地址。
  3. CasAuthenticationEntryPoint 则是 CAS 验证的入口,这里首先设置 CAS Server 的登录地址,同时将前面的 ServiceProperties 设置进去,这样当它登录成功后,就知道往哪里跳转了。
  4. TicketValidator 这是配置 ticket 校验地址,CAS Client 拿到 ticket 要去 CAS Server 上校验,默认校验地址是:https://cas.maqb.org:8443/cas/proxyValidate?ticket=xxx
  5. CasAuthenticationProvider 主要用来处理 CAS 验证逻辑,这里和之前验证码自定义认证逻辑那里类似。当时就说,想要自定义认证逻辑,如短信登录等,都可以通过扩展 AuthenticationProvider 来实现,这里的 CAS 登录当然也不例外,这里虽然设置了一个 userDetailService,但是目的不是为了从数据库中查询数据做校验,因为登录是在 CAS Server 中进行的,这个的作用,我在后面会做介绍。
  6. CasAuthenticationFilter 则是 CAS 认证的过滤器,过滤器将请求拦截下来之后,交由 CasAuthenticationProvider 来做具体处理。
  7. SingleSignOutFilter 表示接受 CAS Server 发出的注销请求,所有的注销请求都将从 CAS Client 转发到 CAS Server,CAS Server 处理完后,会通知所有的 CAS Client 注销登录。
  8. LogoutFilter 则是配置将注销请求转发到 CAS Server。

接下来是定义的 UserDetailsService:

@Component
@Primary
public class UserDetailsServiceImpl implements UserDetailsService{

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return new User(s, "123", true, true, true, true,
                AuthorityUtils.createAuthorityList("ROLE_user"));
    }
}

既然是单点登录,也就是用户是在 CAS Server 上登录的,这里的 UserDetailsService 意义在哪里呢?

用户虽然在 CAS Server 上登录,但是,登录成功之后,CAS Client 还是要获取用户的基本信息、角色等,以便做进一步的权限控制,所以,这里的 loadUserByUsername 方法中的参数,实际上就是你从 CAS Server 上登录成功后获取到的用户名,拿着这个用户名,去数据库中查询用户的相关信心并返回,方便 CAS Client 在后续的鉴权中做进一步的使用,这里我为了方便,就没有去数据库中查询了,而是直接创建了一个 User 对象返回。

接下来,我们再来看看 Spring Security 的配置:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;
    @Autowired
    AuthenticationProvider authenticationProvider;
    @Autowired
    SingleSignOutFilter singleSignOutFilter;
    @Autowired
    LogoutFilter logoutFilter;
    @Autowired
    CasAuthenticationFilter casAuthenticationFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/user/**")
                .hasRole("user")
                .antMatchers("/login/cas").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .and()
                .addFilter(casAuthenticationFilter)
                .addFilterBefore(singleSignOutFilter, CasAuthenticationFilter.class)
                .addFilterBefore(logoutFilter, LogoutFilter.class);
    }
}

这里的配置就简单很多了:

  1. 首先配置 authenticationProvider,这个 authenticationProvider 实际上就是一开始配置的 CasAuthenticationProvider。
  2. 接下来配置 /user/** 格式的路径需要有 user 角色才能访问,登录路径 /login/cas 可以直接访问,剩余接口都是登录成功之后才能访问。
  3. 最后把 authenticationEntryPoint 配置进来,再把自定义的过滤器加进来,这些都比较容易我就不多说了。

最后,再提供两个测试接口:

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

测试

配置 hosts 文件之后,

127.0.0.1	cas.maqb.org
127.0.0.1	client1.cas.maqb.org

浏览器访问 http://client1.cas.maqb.org:8080/user/hello,会自动跳转到 CAS Server 登录界面

image-20210514105158417

空文件

简介

取消

发行版

暂无发行版

贡献者

全部

近期动态

不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
Java
1
https://gitee.com/mqb98/spring-security-sample.git
git@gitee.com:mqb98/spring-security-sample.git
mqb98
spring-security-sample
spring-security-sample
master

搜索帮助