Spring Security 解析(三) —— 个性化认证 以及 RememberMe 实现

Spring Security 解析(三) —— 个性化认证 以及 RememberMe 实现

  在学习Spring Cloud 时,遇到了受权服务oauth 相关内容时,老是只知其一;不知其二,所以决定先把Spring Security 、Spring Security Oauth2 等权限、认证相关的内容、原理及设计学习并整理一遍。本系列文章就是在学习的过程当中增强印象和理解所撰写的,若有侵权请告知。html

项目环境:git

- JDK1.8github

- Spring boot 2.xspring

- Spring Security 5.x数据库

1、个性化认证


(一) 配置登陆

   在 受权过程 和 认证过程 中咱们都是使用的 Security 默认的一个登陆页面(/login),那么若是咱们想自定义一个登陆页面该如何实现呢?其实很简单,咱们新建 FormAuthenticationConfig 配置类,而后在configure(HttpSecurity http) 方法中实现如下设置:json

http.formLogin()
                //能够设置自定义的登陆页面 或者 (登陆)接口
                // 注意1: 通常来讲设置成(登陆)接口后,该接口会配置成无权限便可访问,因此会走匿名filter, 也就意味着不会走认证过程了,因此咱们通常不直接设置成接口地址
                // 注意2: 这里配置的 地址必定要配置成无权限访问,不然将出现 一直重定向问题(由于无权限后又会重定向到这里配置的登陆页url)
                .loginPage(securityProperties.getLogin().getLoginPage())
                //.loginPage("/loginRequire")
                // 指定验证凭据的URL(默认为 /login) ,
                // 注意1:这里修改后的 url 会意味着  UsernamePasswordAuthenticationFilter 将 验证此处的 url
                // 注意2: 与 loginPage设置的接口地址是有 区别, 一但 loginPage 设置了的是访问接口url,那么此处配置将无任何意义
                // 注意3: 这里设置的 Url 是有默认无权限访问的
                .loginProcessingUrl(securityProperties.getLogin().getLoginUrl())
                //分别设置成功和失败的处理器
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler);复制代码

  最后在 SpringSecurityConfig 的 configure(HttpSecurity http) 方法中 调用 formAuthenticationConfig.configure(http) 便可;安全

   正如看到的同样,咱们经过 loginPage()设置 登陆页面接口, 经过 loginProcessingUrl() 设置 UsernamePasswordAuthenticationFilter 要匹配的 接口地址(必定是Post)(看过受权过程的同窗应该都知道其默认的是/login)。 这里有如下几点值得注意:cookie

- loginPage() 这里配置的 地址(不论是接口url仍是登陆页面)必定要配置成无权限访问,不然将出现 一直重定向问题(由于无权限后又会重定向到这里配置的登陆页urlapp

- loginPage() 通常来讲不直接设置成(登陆)接口,由于设置了接口会配置成无权限便可访问(固然设置成登陆页面也须要配置无权限访问),因此会走匿名filter, 也就意味着不会走认证过程了,因此咱们通常不直接设置成接口地址ide

- loginProcessingUrl() 这里修改后的 url 会意味着 UsernamePasswordAuthenticationFilter 将 验证此处的 url

- loginProcessingUrl() 这里设置的 Url 是有默认无权限访问的,与 loginPage设置的接口地址是有 区别, 一但 loginPage 设置了的是接口url,那么此处配置将无任何意义

- successHandler() 和 failureHandler 分别 设置认证成功处理器 和 认证失败处理器 (若是对这2个处理器没印象的话,建议回顾下受权过程)

(二) 配置成功和失败处理器

   在受权过程当中,咱们增简单说起到过这2个处理器,在Security中默认的处理器分别是SavedRequestAwareAuthenticationSuccessHandler 和 SimpleUrlAuthenticationFailureHandler ,此次咱们自定义这2个处理器,分别为 CustomAuthenticationSuccessHandler ( extends SavedRequestAwareAuthenticationSuccessHandler ) 重写 onAuthenticationSuccess() 方法 :

@Component("customAuthenticationSuccessHandler")
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    @Autowired
    private SecurityProperties securityProperties;

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        logger.info("登陆成功");
        // 若是设置了loginSuccessUrl,老是跳到设置的地址上
        // 若是没设置,则尝试跳转到登陆以前访问的地址上,若是登陆前访问地址为空,则跳到网站根路径上
        if (!StringUtils.isEmpty(securityProperties.getLogin().getLoginSuccessUrl())) {
            requestCache.removeRequest(request, response);
            setAlwaysUseDefaultTargetUrl(true);
            setDefaultTargetUrl(securityProperties.getLogin().getLoginSuccessUrl());
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }

}复制代码

和 CustomAuthenticationFailureHandler( extends SimpleUrlAuthenticationFailureHandler) 重写 onAuthenticationFailure() 方法 :

@Component("customAuthenticationFailureHandler")
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SecurityProperties securityProperties;

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

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

        logger.info("登陆失败");
        if (StringUtils.isEmpty(securityProperties.getLogin().getLoginErrorUrl())){

            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));

        } else {
            // 跳转设置的登录失败页面
            redirectStrategy.sendRedirect(request,response,securityProperties.getLogin().getLoginErrorUrl());
        }

    }
}复制代码

(三) 自定义的登录页面

这里就再也不描述,直接贴代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>登陆</title>
</head>
<body>
<h2>登陆页面</h2>
<form action="/loginUp" 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'><input name="remember-me" type="checkbox" value="true"/>记住我</td>
        </tr>
        <tr>
            <td colspan="2">
                <button type="submit">登陆</button>
            </td>
        </tr>
    </table>
</form>
</body>
</html>复制代码

  注意这里请求的地址是 loginProcessingUrl() 配置的地址

(四)测试验证

  这里就不在贴结果图了,只要咱们明白结果流程就行是这样的就能够:localhost:8080 ——> 点击 测试验证Security 权限控制 ————> 跳转到 咱们自定义的 /loginUp.html 登陆页,登陆后 ————> 有配置loginSuccessUrl,则跳转到 loginSuccess.html;反之则直接跳转到 /get_user/test 接口返回结果。 整个流程就全面涉及到了咱们自定义的登陆页面、自定义的登陆成功/失败处理器。

2、 RememberMe (记住我)功能解析

(一)RememberMe 功能实现配置

首先咱们一股脑的将rememberMe配置加上,而后看下现象:复制代码

一、 建立 persistent_logins 表,用于存储token和用户的关联信息:

create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null);复制代码

2 、 添加rememberMe配置 信息

@Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        // 若是token表不存在,使用下面语句能够初始化 persistent_logins(ddl在db目录下) 表;若存在,请注释掉这条语句,不然会报错。
        //tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
    
     @Override
    protected void configure(HttpSecurity http) throws Exception {

        formAuthenticationConfig.configure(http);
        http.   ....
                .and()
                // 开启 记住我功能,意味着 RememberMeAuthenticationFilter 将会 从Cookie 中获取token信息
                .rememberMe()
                // 设置 tokenRepository ,这里默认使用 jdbcTokenRepositoryImpl,意味着咱们将从数据库中读取token所表明的用户信息
                .tokenRepository(persistentTokenRepository())
                // 设置  userDetailsService , 和 认证过程的同样,RememberMe 有专门的 RememberMeAuthenticationProvider ,也就意味着须要 使用UserDetailsService 加载 UserDetails 信息
                .userDetailsService(userDetailsService)
                // 设置 rememberMe 的有效时间,这里经过 配置来设置
                .tokenValiditySeconds(securityProperties.getLogin().getRememberMeSeconds())
                .and()
                .csrf().disable(); // 关闭csrf 跨站(域)攻击防控
    }复制代码

这里解释下配置:

  • rememberMe() 开启 记住我功能,意味着 RememberMeAuthenticationFilter 将会 从Cookie 中获取token信息
  • tokenRepository() 配置 token的获取策略,这里配置成从数据库中读取
  • userDetailsService() 配置 UserDetaisService (若是不熟悉该对象,建议回顾认证过程)
  • tokenValiditySeconds() 设置 rememberMe 的有效时间,这里经过 配置来设置

另外一个重要的配置在登陆页面,这里的 必须是 name="remember-me" ,rememberMe就是经过验证这个配置来开启remermberMe功能的。

<input name="remember-me" type="checkbox" value="true"/>记住我</td>复制代码

  实操结果应该为:进入登录页面 ——> 勾选记住我后登陆 ——> 成功后,查看persistentlogins 表发现有一条数据——> 重启项目 ——> 从新访问须要登陆才能访问的页面,发现无需登陆便可访问——> 删除 persistentlogins 表数据,等待token设置的有效时间过时,而后从新刷新页面发现跳转到登录页面。

(二) RembemberMe 实现源码解析

   首先咱们查看UsernamePasswordAuthenticationFiler(AbstractAuthenticationProcessingFilter) 的 successfulAuthentication() 方法内部源码:

protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {
        
        // 1 设置 认证成功的Authentication对象到SecurityContext中
        SecurityContextHolder.getContext().setAuthentication(authResult);
        
        // 2 调用 RememberMe 相关service处理
        rememberMeServices.loginSuccess(request, response, authResult);

        // Fire event
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                    authResult, this.getClass()));
        }
        //3 调用成功处理器
        successHandler.onAuthenticationSuccess(request, response, authResult);
    }复制代码

其中咱们发现咱们本次重点关注的一行代码: rememberMeServices.loginSuccess(request, response, authResult) , 查看这个方法内部源码:

@Override
    public final void loginSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication successfulAuthentication) {
        // 这里就在判断用户是否勾选了记住我
        if (!rememberMeRequested(request, parameter)) {
            logger.debug("Remember-me login not requested.");
            return;
        }

        onLoginSuccess(request, response, successfulAuthentication);
    }复制代码

经过 rememberMeRequested() 判断是否勾选了记住我。onLoginSuccess() 方法 最终会调用到 PersistentTokenBasedRememberMeServices 的 onLoginSuccess() 方法,贴出其方法源码以下:

protected void onLoginSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication successfulAuthentication) {
        // 1 获取帐户名
        String username = successfulAuthentication.getName();
        
        // 2 建立  PersistentRememberMeToken 对象
        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
                username, generateSeriesData(), generateTokenData(), new Date());
        try {
            // 3 经过 tokenRepository 存储 persistentRememberMeToken 信息
            tokenRepository.createNewToken(persistentToken);
            // 4 将 persistentRememberMeToken 信息添加到Cookie中
            addCookie(persistentToken, request, response);
        }
        catch (Exception e) {
            logger.error("Failed to save persistent token ", e);
        }
    }复制代码

分析下源码步骤:

  • 获取 帐户信息 username
  • 传入 username 建立 PersistentRememberMeToken 对象
  • 经过 tokenRepository 存储 persistentRememberMeToken信息
  • 将 persistentRememberMeToken 信息添加到Cookie中

  这里的 tokenRepository 就是咱们配置 rememberMe功能所设置的。通过上面的解析咱们看到了rememberServices 将 建立一个 token 信息,并存储到数据库(由于咱们配置的是数据库存储方式 JdbcTokenRepositoryImpl )中,并将token信息添加到Cookie中了。到这里,咱们看到了RememberMe实现前的一些业务处理,那么后面如何实现RememberMe,我想你们内心大概都有个底了。这里直接抛出以前受权过程当中咱们没有说起到的 filter 类 RememberMeAuthenticationFilter,它是介于 UsernamePasswordAuthenticationFilter 和 AnonymousAuthenticationFilter 之间的一个filter,它主要负责的就是前面的filter都没有认证成功后从Cookie中获取token信息而后再经过tokenRepository 获取 登陆用户名,而后UserDetailsServcie 加载 UserDetails 信息 ,最后建立 Authticaton(RememberMeAuthenticationToken) 信息再调用 AuthenticationManager.authenticate() 进行认证过程。

RememberMeAuthenticationFilter

  咱们来看下 RememberMeAuthenticationFilter 的dofiler方法源码:

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) {
            //  1 调用 rememberMeServices.autoLogin() 获取Authtication 信息
            Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
                    response);

            if (rememberMeAuth != null) {
                // Attempt authenticaton via AuthenticationManager
                try {
                    // 2 调用 authenticationManager.authenticate() 认证
                    rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
                    
                    ......
                    }

                }
                catch (AuthenticationException authenticationException) {
                .....
            }

            chain.doFilter(request, response);
        }复制代码

咱们主要关注 rememberMeServices.autoLogin(request,response) 方法实现,查看器源码:

@Override
    public final Authentication autoLogin(HttpServletRequest request,
            HttpServletResponse response) {
        // 1 从Cookie 中获取 token 信息
        String rememberMeCookie = extractRememberMeCookie(request);

        if (rememberMeCookie == null) {
            return null;
        }
        
        if (rememberMeCookie.length() == 0) {
            cancelCookie(request, response);
            return null;
        }

        UserDetails user = null;

        try {
            // 2 解析 token信息
            String[] cookieTokens = decodeCookie(rememberMeCookie);
            // 3 经过 token 信息 生成 Uerdetails 信息
            user = processAutoLoginCookie(cookieTokens, request, response);
            userDetailsChecker.check(user);

            logger.debug("Remember-me cookie accepted");
            // 4 经过 UserDetails 信息建立 Authentication 
            return createSuccessfulAuthentication(request, user);
        } 
        .....
    }复制代码

内部实现步骤:

  • 从Cookie中获取 token 信息并解析
  • 经过 解析的token 生成 UserDetails (processAutoLoginCookie() 方法实现 )
  • 经过 UserDetails 生成 Authentication ( createSuccessfulAuthentication() 建立 RememberMeAuthenticationToken )

其中最关键的一部是 processAutoLoginCookie() 方法是如何生成UserDetails 对象的,咱们查看这个方法源码实现:

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
            HttpServletRequest request, HttpServletResponse response) {
        final String presentedSeries = cookieTokens[0];
        final String presentedToken = cookieTokens[1];
        // 1 经过 tokenRepository 加载数据库token信息
        PersistentRememberMeToken token = tokenRepository
                .getTokenForSeries(presentedSeries);

        PersistentRememberMeToken newToken = new PersistentRememberMeToken(
                token.getUsername(), token.getSeries(), generateTokenData(), new Date());
        // 2 判断 用户传入token和数据中的token是否一致,不一致可能存在安全问题
        if (!presentedToken.equals(token.getTokenValue())) {
            tokenRepository.removeUserTokens(token.getUsername());
            throw new CookieTheftException(
                    messages.getMessage(
                            "PersistentTokenBasedRememberMeServices.cookieStolen",
                            "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
        }
        try {
            // 3 更新 token 并添加到Cookie中
            tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
                    newToken.getDate());
            addCookie(newToken, request, response);
        }
        catch (Exception e) {
            throw new RememberMeAuthenticationException(
                    "Autologin failed due to data access problem");
        }
        // 4 经过 UserDetailsService().loadUserByUsername() 方法加载UserDetails 信息并返回
        return getUserDetailsService().loadUserByUsername(token.getUsername());
    }复制代码

咱们看下其内部步骤:

  • 经过 tokenRepository 加载数据库token信息
  • 判断 用户传入token和数据中的token是否一致,不一致可能存在安全问题
  • 更新 token 并添加到Cookie中
  • 经过 UserDetailsService().loadUserByUsername() 方法加载UserDetails 信息并返回

   看到这里相信你们如下就明白了,当初为啥在启用rememberMe功能时要配置 tokenRepository 和 UserDetailsService了。

这里我就再也不演示整个实现的流程了,老规矩,上流程图:

https://user-gold-cdn.xitu.io/2019/8/28/16cd892ee6146b4c?w=1283&h=504&f=jpeg&s=96642

   本文介绍个性化认证和RememberMe的代码能够访问代码仓库中的 security 模块 ,项目的github 地址 : https://github.com/BUG9/spring-security

         若是您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!

复制代码
相关文章
相关标签/搜索