spring security——用户认证流程(二)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_22172133/article/details/99942465

打个广告,帮朋友卖点东西,东西超便宜的哟【衣服鞋子等】,厂家直接出货,绝对低于市场价!!! 一般都比市场价便宜3—7折【都是牌子货】,如果您感兴趣,可以扫描屏幕下方的二维码,感谢关注!!!

微信

一、用户认证逻辑

        在自定义用户认证逻辑中我们需要完成的内容有:

  • 处理用户信息获取逻辑
  • 处理用户校验逻辑
  • 处理密码加密解密

1、处理用户信息获取逻辑(UserDetailsService)

org.springframework.security.core.userdetails.UserDetailsService

UserDetailsService接口用于加载用户特定的数据,它在整个框架中作为用户DAO使用,是验证提供者使用的策略。 该接口只需要一个只读方法,这简化了对新的数据访问策略的支持。实现一个自定义的UserDetailsService

// 自定义数据源来获取数据
// 这里只要是存在一个自定义的 UserDetailsService ,那么security将会使用该实例进行配置
@Component
public class MyUserDetailsService implements UserDetailsService {
    Logger logger = LoggerFactory.getLogger(getClass());
​
    // 可以从任何地方获取数据
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查找用户信息
        logger.info("登录用户名", username);
        // 写死一个密码,赋予一个admin权限
        return new User(username, "123456",
                        AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

这样就能让自定义的UserdetailsService生效了。但是在浏览器中登录的时候,后台报错了

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

这个异常是spring security5+后密码策略变更了。必须使用 PasswordEncoder 方式,也就是你存储密码的时候需要使用{noop}123456这样的方式。这个在官网文档中有讲到,花括号里面的是encoder id ,这个支持的全部列表在以下的方法中定义

org.springframework.security.crypto.factory.PasswordEncoderFactories#createDelegatingPasswordEncoder

noop 对应的处理类是org.springframework.security.crypto.password.NoOpPasswordEncoder,只用于测试,因为没有做任何加密功能。

2、处理用户校验逻辑(UserDetails)

        自定义的其他逻辑是在 org.springframework.security.core.userdetails.User 中提供的,只要在登录的时候把user中提供的信息返回即可达到支持的业务逻辑,下面列出支持的业务场景:

  • isEnabled 账户是否启用
  • isAccountNonExpired 账户没有过期
  • isCredentialsNonExpired 身份认证是否是有效的
  • isAccountNonLocked 账户没有被锁定 

对于 isAccountNonLocked 和 isEnabled 没有做业务处理,只是抛出了对于的异常信息;

// 这里的几个布尔值的含义对应上面列出来的顺序
User admin = new User(username, "{noop}123456",
                      true, true, true, false,
                      AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));

3、处理密码加密解密(PasswordEncoder)

        密码加密解密是使用了下面这个类

org.springframework.security.crypto.password.PasswordEncoder

配置只需要提供一个实例即可,会自动使用该实例

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

在UserDetailsService中需要模拟存入数据库中的密码就是加密后的字符串

@Component
public class MyUserDetailsService implements UserDetailsService {
    Logger logger = LoggerFactory.getLogger(getClass());
​
    @Autowired
    private PasswordEncoder passwordEncoder;
​
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("登录用户名:{}", username);
        String password = passwordEncoder.encode("123456");
        logger.info("数据库密码{}", password);
        User admin = new User(username,
// "{noop}123456",
                              password,
                              true, true, true, true,
                              AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

其框架会把提交的密码使用我们定义的passwordEncode加密后调用org.springframework.security.crypto.password.PasswordEncoder#matches方法,与 返回的User中的密码进行比对。配对正常就验证通过。

二、自定义登录

        上面使用spring security默认的认证流程。处理了自定义的用户数据,密码加密等。但是在实际开发中,肯定是要使用自己开发的页面、登录成功失败的业务处理等。

1、自定义登录页面

        我们在自定义的WebSecurityConfig中修改配置如下:

protected void configure(HttpSecurity http) throws Exception {
    http
        // 定义表单登录 - 身份认证的方式
        .formLogin()
        .loginPage("/tin-signIn.html")
        .and()
        .authorizeRequests()
        // 放行这个路径
        .antMatchers("/tin-signIn.html").permitAll()
}

如果不对该静态文件放行的话,将会无限跳转,造成错误:"localhost 将您重定向的次数过多"。关于html存放资源文件夹下的哪一个目录,这个有点懵,开始放在static下面访问不到,最后放到resources下面才对(因为我的项目没有整合Thymeleaf,spring boot的默认静态文件路径应该是下面的)

html内容如下

<body>
<h2>标准登录页面</h2>
<form action="/authentication/form" method="post">
    <table>
        <tr>
            <td>用户名:</td>
            <td><input type="text" name="username"></td>
        </tr>
        <tr>
            <td>密码:</td>
            <td><input type="password" name="password"></td>
        </tr>
        <tr>
            <td colspan="2">
                <button type="submit">登录</button>
            </td>
        </tr>
    </table>
</form>
</body>

注意这里的路径:acrion="/authentication/form",路径是自定义的,而UsernamePasswordAuthenticationFilter 默认是处理/login路径的登录请求

public UsernamePasswordAuthenticationFilter() {
  super(new AntPathRequestMatcher("/login", "POST"));
}

这里我们自定义如下:

.formLogin()
.loginPage("/tin-signIn.html")
// 处理登录请求路径,像是对security默认/login路径的重命名
.loginProcessingUrl("/authentication/form")
.and()
.authorizeRequests()
.antMatchers("/tin-signIn.html").permitAll()
.and()
.csrf().disable();//不加出现_csrf的错误情况,跳回登录页面

2、处理不同类型的请求

        上面虽然配置了自定义的路径,但是都统一跳转到了静态页面,那么要怎么实现 根据请求来分发是返回html内容?还是返回json内容呢?在前后分离的情况下,都用ajax来请求,肯定不能返回html了。

思路是上面图中这样。那么很简单只要把登录地址换成自定义的就好了。

.formLogin()
    //.loginPage("/tin-signIn.html")
    // 更换成自定义的一个真实存在的处理器地址
    .loginPage("/authentication/require")

编写处理请求的处理器

@RestController
@Slf4j
public class BrowserSecurityController {
    // 封装了引发跳转请求的工具类,看实现类应该是从session中获取的
    private RequestCache requestCache = new HttpSessionRequestCache();
​
    // spring的工具类:封装了所有跳转行为策略类
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
​
    /**
     * 当需要身份认证时跳转到这里
     */
    @RequestMapping("/authentication/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponse requirAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
​
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        // 如果有引发认证的请求
        // spring 在跳转前应该会把信息存放在某个地方?
        if (savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            logger.info("引发跳转的请求:" + targetUrl);
            // 如果是html请求,则跳转到登录页
            if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
                redirectStrategy.sendRedirect(request, response, "/tin-signIn.html");
            }
        }
        // 否则都返回需要认证的json串
        return new SimpleResponse("访问的服务需要身份认证,请引导用户到登录页");
    }
}

SimpleResponse 类只是一个返回结果的信息类。

3、登录成功处理

        security 默认的登录成功处理是跳转到需要授权之前访问的url。而在一些场景下:比如前后分离,登录是通过ajax访问,没有办法处理301跳转。而是登录成功则返回相关的数据即可。自定义入口还是在表单登录处配置的

http
        // 定义表单登录 - 身份认证的方式
        .formLogin()
        .loginPage("/authentication/require")
        .loginProcessingUrl("/authentication/form")
        .successHandler(myAuthenticationSuccessHandler)

myAuthenticationSuccessHandler 的编写如下:

/**
 * formLogin().successHandler() 中需要的处理器类型
 */
@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());
​
    // spring 是使用jackson来进行处理返回数据的,所以这里可以得到他的实例
    @Autowired
    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
​
    /**
     * authentication 封装了所有的认证信息
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");
​
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

查看输出的authentication

{
    "authorities": [
        {
            "authority": "admin"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": "FE0F33577E7E5D89AF15FCCD6FE5A4B3"
    },
    "authenticated": true,
    "principal": {
        "password": null,
        "username": "admin",
        "authorities": [
            {
                "authority": "admin"
            }
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null,
    "name": "admin"
}

如果我们想在ajax访问时返回JSON数据,在普通处理下跳转到之前访问的url该怎么办?

@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());
​
    // com.fasterxml.jackson.databind.
    // spring 是使用jackson来进行处理返回数据的
    // 所以这里可以得到他的实例
    @Autowired
    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
​
    @Autowired
    private SecurityProperties securityProperties;
​
    /**
     * @param authentication 封装了所有的认证信息
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");
        if (securityProperties.getBrowser().getLoginType() == LoginType.JSON) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(authentication));
        } else {
            // 把本类实现父类改成 AuthenticationSuccessHandler 的子类 SavedRequestAwareAuthenticationSuccessHandler
            // 之前说spring默认成功是跳转到登录前的url地址
            // 就是使用的这个类来处理的
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}

这样登录成功的就ok了。对于失败的来说是一样的,继承的父类改成spring默认的处理器

org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler

下面简单写一下失败处理器,其实和成功处理器一样

@Component("myAuthenticationFailureHandler")
@Slf4j
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
​
    @Autowired
    private SecurityProperties securityProperties;
​
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
​
        log.info("用户信息校验失败:" + exception.getMessage());
​
        if(LoginResponseType.JSON.equals(securityProperties.getBrowser().getLoginType())){
            //response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JsonUtils.objectToJson(RestResult.build(CodeMsg.USER_LOGIN_FAILURE)));
        }else {
            super.onAuthenticationFailure(request, response, exception);
        }
    }
}

三、认证流程详解

        上面讲的都是实现spring给出的扩展钩子。比如:自定义登录页,自定义登录成功处理等。但是是很碎片化的,在脑海中链接不起来,下面我们就来梳理一下用户认证流程的源码。

下面我们就跟着这个流程和之前配置的地方,去一步一步的看源码

  • AuthenticationManager 管理所有的Provider,并选择适合的进行验证
  • AuthenticationProvider 验证提供者,可以自己写provider处理自己的业务场景逻辑

1、认证结果如何在多个请求之间共享

        在认证成功的情况下,SecurityContext 默策略是一个org.springframework.security.core.context.ThreadLocalSecurityContextHolderStrategy 对象,内部使用`ThreadLocal<SecurityContext>`来存储

org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication
​
SecurityContextHolder.getContext().setAuthentication(authResult);

上面的源码也就是上图右边部分,SecurityContext会在同一个线程中可以通过这个context来获取到的。然后SecurityContextPersistenceFilter 所在的位置如下图

2、获取认证用户信息

        这里我们在UserController中写获取用户信息的api。在前面分析了源码,所以这里可以使用SecurityContextHolder获取到当前的认证用户信息

@GetMapping("/me")
public Authentication getCurrentUser() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    return authentication;
}
​
// 这样写 spring 会自动注入
@GetMapping("/me")
public Authentication getCurrentUser(Authentication authentication) {
    return authentication;
}

输出

{
    "authorities": [
        {
            "authority": "admin"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": "CB434B240B0B1810ED922C9D877F5842"
    },
    "authenticated": true,
    "principal": {
        "password": null,
        "username": "test001",
        "authorities": [
            {
                "authority": "admin"
            }
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null,
    "name": "test001"
}

注意看,password是不会返回来的!如果上面的信息太多了,也可以使用参数注解@AuthenticationPrincipal UserDetails获取User的信息。比如

public RestResult validatePassword(@AuthenticationPrincipal UserExt user, String password) {
    return userService.validatePassword(user, password);
}

四、添加记住我功能

1、基本原理

        在security中认证过滤链中的org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter 过滤器来实现的。 

当没有其他的认证过滤器处理的时候,记住我这个过滤器就尝试工作。

2、实现记住我的功能

        首先给默认登录页面增加选项

<!--名称是固定的-->
<input type="checkbox" value="true" name="remember-me">记住我

nam的名称是以下类中被定义

org.springframework.security.config.annotation.web.configurers.RememberMeConfigurer
​
private static final String DEFAULT_REMEMBER_ME_NAME = "remember-me";

然后在SecurityConfig 中进行配置

public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
​
  // 数据源是需要在使用处配置数据源的信息
   @Autowired
   private DataSource dataSource;
   @Autowired
   private PersistentTokenRepository persistentTokenRepository;
   // 之前已经写好的 MyUserDetailsService,
   @Autowired
   private UserDetailsService userDetailsService;
​
   @Bean
   public PersistentTokenRepository persistentTokenRepository() {
       // org.springframework.security.config.annotation.web.configurers.RememberMeConfigurer.tokenRepository
       JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
       jdbcTokenRepository.setDataSource(dataSource);
       // 该对象里面有定义创建表的语句,可以设置让该类来创建表
       // 但是该功能只用使用一次,如果数据库已经存在表则会报错
        // jdbcTokenRepository.setCreateTableOnStartup(true);
       return jdbcTokenRepository;
   }
​
   @Override
   protected void configure(HttpSecurity http) throws Exception {
     http
         // 定义表单登录 - 身份认证的方式
         .formLogin()
         .loginPage("/authentication/require")
         .loginProcessingUrl("/authentication/form")
         .and()
         // 从这里开始配置记住我的功能
         .rememberMe()
         .tokenRepository(persistentTokenRepository)
         // 新增过期配置,单位秒,默认配置写的60秒
         .tokenValiditySeconds(60 * 5)
         // userDetailsService 是必须的。不然就报错
         .userDetailsService(userDetailsService)

从数据库获取到记住我的token后,验证成功,则通过userDetailsService获取用户信息,然后在框架中写入认证信息,完成登录。

3、测试

        访问/user 肯定被拦截,无权限访问。访问标准登录页:/tin-signIn.html 记住勾选记住我的选项。退出浏览器或则重启系统,直接访问 /user(此时会明显的感觉到系统运行缓慢,这是因为需要从数据库获取信息)。在登录成功的时候会往数据库插入一条数据

INSERT INTO `tin-demo`.`persistent_logins` (`username`, `series`, `token`, `last_used`) VALUES 
('admin', 'eBbVlKvXSseasbH6yVGozQ==', '72sHS9ifyza2yTN3h0Kq7A==', '2018-08-04 14:05:31');

登录一次则会插入一条,那什么时候被删除呢?在源码中看到:

  • 当携带的cookie和数据库中最后一次不匹配的时候,会删除所有与该用户相关的记录
  • 当使用logout功能退出的时候

所以该地方可能会有一些小小的问题,但是可以自己去解决这个数据过多的情况,增加定时任务多长时间清理一次等方法。

4、源码解析

        登录在验证成功之后会调用该方法org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication。然后委托了org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices#onLoginSuccess

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

携带cookie访问的时候会触发这个方法org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices#processAutoLoginCookie,里面有验证过期等的逻辑。

猜你喜欢

转载自blog.csdn.net/qq_22172133/article/details/99942465