Spring Security---ONE

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security---ONE相关的知识,希望对你有一定的参考价值。


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

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


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

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

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


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

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

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

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


PasswordEncoder详解

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

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


PasswordEncoder 接口

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

public interface PasswordEncoder 
   String encode(CharSequence rawPassword);

   boolean matches(CharSequence rawPassword, String encodedPassword);

   default boolean upgradeEncoding(String encodedPassword) 
      return false;
   

这个接口有三个方法

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

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

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

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


接口实现类

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

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

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

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

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

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

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

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

$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm

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

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

formLogin模式登录认证

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

对于一个完整的应用系统,与登录验证相关的页面都是高度定制化的,非常美观而且提供多种登录方式。这就需要Spring Security支持我们自己定制登录页面,也就是本文给大家介绍的formLogin模式登录认证模式。

需要注意的是:有的朋友会被Form Login这个名字误解,Form Login不是只有使用html中的form 表单才能实现登录功能,使用js发起登录请求也是可以的

准备工作

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

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

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


说明

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

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

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


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

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

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

上面的代码分为两部分:

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

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

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


用户及角色信息配置

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

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


@Bean
public PasswordEncoder passwordEncoder()
    return new BCryptPasswordEncoder();

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

静态资源访问

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

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

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

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

源码解析登录验证流程

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

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

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

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

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


过滤器登录验证细节

构建登录认证主体

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

多种认证方式的管理 ProviderManager

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

public interface AuthenticationManager 
    Authentication authenticate(Authentication authentication) throwsAuthenticationException;

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

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

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

public interface AuthenticationProvider 
    Authentication authenticate(Authentication var1) throws AuthenticationException;
    boolean supports(Class<?> var1);

  • RememberMeAuthenticationProvider定义了“记住我”功能的登录验证逻辑
  • DaoAuthenticationProvider加载数据库用户信息,进行用户密码的登录验证

数据库加载用户信息 DaoAuthenticationProvider

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider 

从数据库获取用户信息

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

SecurityContext

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


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

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

UsernamePasswordAuthenticationFilter

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

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

AbstractAuthenticationProcessingFilter的doFilter方法的验证过程

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

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

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

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

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


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

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

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

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

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

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

@Component
public class MyAuthenticationSuccessHandler 
                        extends SavedRequestAwareAuthenticationSuccessHandler 

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

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

        if (loginType.equalsIgnoreCase("JSON")) 
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(AjaxResponse.success()));
         else 
            // 会帮我们跳转到上一次请求的页面上
            super.onAuthenticationSuccess(request, response, authentication);
        
    

  • 在上面的自定义登陆成功处理中,既适应JSON前后端分离的应用登录结果处理,也适用于模板页面跳转应用的登录结果处理
  • ObjectMapper 是Spring Boot默认集成的JSON数据处理类库Jackson中的类。
  • AjaxResponse是一个自定义的通用的JSON数据接口响应类。

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

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

@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler 

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

    private  static ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response, 
                                        AuthenticationException exception)<

以上是关于Spring Security---ONE的主要内容,如果未能解决你的问题,请参考以下文章

初识Spring源码 -- doResolveDependency | findAutowireCandidates | @Order@Priority调用排序 | @Autowired注入(代码片段

Spring boot:thymeleaf 没有正确渲染片段

What's the difference between @Component, @Repository & @Service annotations in Spring?(代码片段

spring练习,在Eclipse搭建的Spring开发环境中,使用set注入方式,实现对象的依赖关系,通过ClassPathXmlApplicationContext实体类获取Bean对象(代码片段

Spring Rest 文档。片段生成时 UTF-8 中间字节无效 [重复]

解决spring-boot启动中碰到的问题:Cannot determine embedded database driver class for database type NONE(转)(代码片段