SpringSecurity(2)---記住我功能實現

雨點的名字發表於2020-06-04

SpringSecurity(2)---記住我功能實現

上一篇部落格實現了認證+授權的基本功能,這裡在這個基礎上,新增一個 記住我的功能

上一篇部落格地址:SpringSecurity(1)---認證+授權程式碼實現

說明:上一遍部落格的 使用者資料使用者關聯角色 的資訊是在程式碼裡寫死的,這篇將從mysql資料庫中讀取。

一、資料庫建表

這裡建了三種表

SpringSecurity(2)---記住我功能實現

一般許可權表有四張或者五張,這裡有關 角色關聯資源表 沒有建立,角色和資源的關係依舊在程式碼裡寫死。

建表sql

/*建立使用者表*/
CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) NOT NULL,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

/*建立j角色表*/
CREATE TABLE `roles` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;

/*建立使用者關聯角色表*/
CREATE TABLE `roles_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `rid` int DEFAULT '2',
  `uid` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=133 DEFAULT CHARSET=utf8;


/*這裡密碼對應的明文 還是123456*/
INSERT INTO `user` (`id`, `username`, `nickname`, `password`, `enabled`)
VALUES
	(1, '小小', '小小', 'e10adc3949ba59abbe56e057f20f883e', 1);

/*三種角色*/
INSERT INTO `roles` (`id`, `name`)
VALUES
	(1, '校長'),
	(2, '教師'),
	(3, '學生');
	
/*小小使用者關聯了 教師和校長角色*/
INSERT INTO `roles_user` (`id`, `rid`, `uid`)
VALUES
	(1, 2, 1),
	(2, 3, 1);

說明:這裡資料庫只有一個使用者

使用者名稱 :小小

密碼:123456

她所擁有的角色有兩個 教師學生


二、Spring Security的記住我功能基本原理

概念 記住我在登陸的時候都會被使用者勾選,因為它方便地幫助使用者減少了輸入使用者名稱和密碼的次數,使用者一旦勾選記住我功能那麼 當伺服器重啟後依舊可以不用登陸就可以訪問

Spring Security的“記住我”功能的基本原理流程圖如下所示:

SpringSecurity(2)---記住我功能實現

這裡大致流程如下:

第一次登陸

使用者請求的時候 remember-me引數為true 時,使用者先進行 認證+授權過濾器。然後走記住我過濾器這裡需要做兩,這裡主要做兩件事。

1.將Token資料存入資料庫 2.將token資料存入cookie中。

服務重啟後

如果服務重啟的話,那麼之前的session資訊已經不在了,但是cookie中的Token還是存在的。所以當使用者重啟後去訪問需要認證的介面時,會先通過cookie中的Token

去資料庫查詢這條Token資訊,如果存在那麼在通過使用者名稱去查詢資料庫獲取當前使用者的資訊。


三、程式碼實現

因為上面專案已經完成了整個授權+認證的過程,那麼這裡就很簡單新增一點點程式碼就可以了。

在WebSecurityConfig中新增一個Bean,配置完這個Bean就基本完成了 記住我 功能的開發,然後在將這個Bean設定到configure方法中即可。

    @Bean
    public PersistentTokenRepository tokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

上面的程式碼 tokenRepository.setCreateTableOnStartup(true) ;是自動建立Token存到資料庫時候所需要的表,這行程式碼只能執行一次,如果重新啟動資料庫,

必須刪除這行程式碼,否則將報錯,因為在第一次啟動的時候已經建立了表,不能重複建立。保險起見我們還是註釋掉這段程式碼,手動建這張表。

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

在配置裡再加上這些就可以了。

SpringSecurity(2)---記住我功能實現

四、測試

主要測試兩個點地方,

1、當我登陸時選擇記住我功能,看下資料庫persistent_logins是否有一條token記錄
2、當使用記住我功能後,關閉伺服器在重啟伺服器,不再登陸直接訪問需要認證的介面,看是否能夠訪問成功。

1、首次登陸

SpringSecurity(2)---記住我功能實現

我們在看資料庫token表

SpringSecurity(2)---記住我功能實現

很明顯新增了一條token資料。

2、重啟伺服器

這個時候我們重啟伺服器訪問需要認證的介面

SpringSecurity(2)---記住我功能實現

發現就算重啟也不需要重啟登陸就可以反問需要認證的介面。


五、原始碼分析

同樣這裡也分為兩部分 1、第一次登陸原始碼流程。 2、重啟後未認證再去訪問需要認證的介面原始碼流程。

1、首次登陸原始碼流程

第一步

當使用者傳送登入請求的時候,首先到達的是UsernamePasswordAuthenticationFilter這個過濾器,然後執行attemptAuthentication方法的程式碼,程式碼如下圖所示:

 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
 //從這裡可以看出登陸需要post提交
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

            if (password == null) {
                password = "";
            }

            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

之後所走的流程就是 ProviderManager的authenticate方法 ,之後再走AbstractUserDetailsAuthenticationProvider的authenticate方法,再走DaoAuthenticationProvider的方法retrieveUser方法

 protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        this.prepareTimingAttackProtection();

        try {
            //這裡就走我們自定義的獲取使用者認證和授權資訊的程式碼了
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException var4) {
            this.mitigateAgainstTimingAttack(authentication);
            throw var4;
        } catch (InternalAuthenticationServiceException var5) {
            throw var5;
        } catch (Exception var6) {
            throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
        }
    }

這樣一來,認證的流程就已經走完了。那就要走記住我功能的過濾器了。

第二步

驗證成功之後,將進入AbstractAuthenticationProcessingFilter 類的successfulAuthentication的方法中,首先將認證資訊通過程式碼
SecurityContextHolder.getContext().setAuthentication(authResult);將認證資訊存入到session中,緊接著這個方法中就呼叫了rememberMeServices的loginSuccess方法

 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);
        //記住我
        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }

再走PersistentTokenBasedRememberMeServices的onLoginSuccess方法

    protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();
        this.logger.debug("Creating new persistent login for user " + username);
        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

        try {
            //這裡就是關鍵的兩步 1、將token存入到資料庫 2、將token存入cookie中
            this.tokenRepository.createNewToken(persistentToken);
            this.addCookie(persistentToken, request, response);
        } catch (Exception var7) {
            this.logger.error("Failed to save persistent token ", var7);
        }

    }

這個方法中呼叫了tokenRepository來建立Token並存到資料庫中,且將Token寫回到了Cookie中。到這裡,基本的登入過程基本完成,生成了Token存到了資料庫,

且寫回到了Cookie中。

2、第二次訪問

重啟專案,這時候伺服器端的session已經不存在了,但是第一次登入成功已經將Token寫到了資料庫和Cookie中,直接訪問一個服務,並且不輸入使用者名稱和密碼。

第一步

首先進入到了RememberMeAuthenticationFilter的doFilter方法中,這個方法首先檢查在session中是否存在已經驗證過的Authentication了,如果為空,就進行下面的

RememberMe的驗證程式碼,比如呼叫rememberMeServices的autoLogin方法,程式碼如下:

    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 = this.rememberMeServices.autoLogin(request, response);
            //省略不重要的程式碼
            chain.doFilter(request, response);
        } else {
            chain.doFilter(request, response);
        }
    }

我們在看this.rememberMeServices.autoLogin(request, response)方法。最終實現在AbstractRememberMeServices的autoLogin方法

    public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
        //1、獲取token
        String rememberMeCookie = this.extractRememberMeCookie(request);
        if (rememberMeCookie == null) {
            return null;
        } else {
    
                UserDetails user = null;
                try {
                    String[] cookieTokens = this.decodeCookie(rememberMeCookie);
                    //這步是關鍵
                    user = this.processAutoLoginCookie(cookieTokens, request, response);
                    this.userDetailsChecker.check(user);
                    this.logger.debug("Remember-me cookie accepted");
                    return this.createSuccessfulAuthentication(request, user);
                } catch (CookieTheftException var6) {
                    this.cancelCookie(request, response);
                    throw var6;
                } 
                this.cancelCookie(request, response);
                return null;
            }
        }
    }

我們在看 this.processAutoLoginCookie(cookieTokens, request, response);在PersistentTokenBasedRememberMeServices中實現,到這一步就已經很明白了

 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) + "'");
        } else {
            String presentedSeries = cookieTokens[0];
            String presentedToken = cookieTokens[1];
            //1、去token表中查詢token
            PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
            if (token == null) {
                throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
                //2校驗資料
            } else if (!presentedToken.equals(token.getTokenValue())) {
                this.tokenRepository.removeUserTokens(token.getUsername());
                throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
                //3、檢視token是否過期
            } else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
                throw new RememberMeAuthenticationException("Remember-me login has expired");
            } else {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
                }

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

                try {
                //4、更新這條token 沒更新一次有效時間就都變成了之間設定的時間
                    this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
                    this.addCookie(newToken, request, response);
                } catch (Exception var9) {
                    this.logger.error("Failed to update token: ", var9);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
                }
                 //5、這裡拿著使用者名稱 就又獲取當前使用者的認證和授權資訊
                return this.getUserDetailsService().loadUserByUsername(token.getUsername());
            }
        }
    }

這樣整個流程就完成了,我們可以看出原始碼的過程和上面圖片展示的流程還是非常像的。



別人罵我胖,我會生氣,因為我心裡承認了我胖。別人說我矮,我就會覺得好笑,因為我心裡知道我不可能矮。這就是我們為什麼會對別人的攻擊生氣。
攻我盾者,乃我內心之矛(18)

相關文章