Spring security - user logout 與自動登入

October 30, 2021

logout

以下是一個使用者登出後的配置,登出成功後頁面會跳轉到 /api/v1/index

@Override
    protected void configure(HttpSecurity http) throws Exception {
        // TODO Auto-generated method stub
        // logout
        http.logout().logoutUrl("/logout").logoutSuccessUrl("/api/v1/index").permitAll();
        http.exceptionHandling().accessDeniedPage("/403.html");
        http.formLogin() // 自定義編寫登入頁面
            ...
    }

撰寫一個登出頁面如下,同時也修改上面的 defaultSuccessUrl 配置將其換成 defaultSuccessUrl("/success.html")

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Success</title>
</head>
<body>
    <h1>Success</h1>
    <br>
    <a href="/logout"><b>logout</b></a>
</body>
</html>

實驗過程可以請求 http://localhost:8080/login.html 會跳轉到 http://localhost:8080/success.html 此時可以進行頁面的 API 請求。

自動登入

要實現的話會將數據儲存自 cookiedatabase,並進行比對。整體架構如下

 ________________                                                                _____________________________
|                |             _____________________________________            |                            |
|                |  1         |                                    |      2     |                            |
|                |----------->| UsernamePassworAuthenticationFilter|----------->|                            |
|                |            |____________________________________|            |                            |
|                |                                                              |    ___________________     |                  ________
|                |                                                              |    | RemeberMeService|     |                 |        |
|    browser     |                                                              |    |_________________|     |       4         |        |
|                |                            3                                 |    ___________________     | --------------> |   DB   |
|                |<-------------------------------------------------------------|    |  TokenRepository|     |       13        |        |
|                |                                                              |    |_________________|     | --------------> |________|
|                |                                                              |                            |                 
|                |             _____________________________________            |                            |
|                |  11        |                                    |            |                            |                 ______________________
|                |            |                                    |     12     |                            |       14       |                     |
|                |----------->|   RememberMeAuthenticationFilter   |----------->|                            | -------------->|  UserDetailsService |
|________________|            |____________________________________|            |____________________________|                |_____________________|

初始請求

  1. 認證請求
  2. 認證成功,調用 successfulAuthentication
  3. 將 Token 寫入瀏覽起的 cookie
  4. 將 Token 寫入 DB 在發送請求
  5. 服務請求
  6. 讀取 cookie 中的 Token
  7. 查找 Token

UsernamePasswordAuthenticationFilter 所繼層的抽象類 AbstractAuthenticationProcessingFilter 中有一個 successfulAuthentication 方法,裡面有一個 rememberMeServices 它調用了 loginSuccess

    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		SecurityContextHolder.getContext().setAuthentication(authResult); // 獲取當前用戶權限
		if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", 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);
	}

loginSuccess 實現如下

    @Override
	public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication) {
		if (!rememberMeRequested(request, this.parameter)) {
			this.logger.debug("Remember-me login not requested.");
			return;
		}
		onLoginSuccess(request, response, successfulAuthentication);
	}

當中在查看 onLoginSuccess 實現,

   // PersistentTokenBasedRememberMeServices class
    @Override
	protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication) {
		String username = successfulAuthentication.getName();
		this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));
		PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
				generateTokenData(), new Date());
		try {
			this.tokenRepository.createNewToken(persistentToken); // 建立 Token
			addCookie(persistentToken, request, response); // 新增至 cookie
		}
		catch (Exception ex) {
			this.logger.error("Failed to save persistent token ", ex);
		}
	}

在往 JdbcTokenRepositoryImpl 追,可以知道說 createNewToken 與資料庫的交互

    @Override
	public void createNewToken(PersistentRememberMeToken token) {
		getJdbcTemplate().update(this.insertTokenSql, token.getUsername(), token.getSeries(), token.getTokenValue(),
				token.getDate());
	}

接著再次請求後會觸發 RememberMeAuthenticationFilter,其裡面 doFilter 方法它做了以下事情

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (SecurityContextHolder.getContext().getAuthentication() != null) {
			this.logger.debug(LogMessage
					.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
							+ SecurityContextHolder.getContext().getAuthentication() + "'"));
			chain.doFilter(request, response);
			return;
		}
		Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); // 自動登入,會從 cookie 拿值判別是否為空
		if (rememberMeAuth != null) {
			// Attempt authenticaton via AuthenticationManager
			try {
				rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
				// Store to SecurityContextHolder
				SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
				onSuccessfulAuthentication(request, response, rememberMeAuth);
				this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'"));
				if (this.eventPublisher != null) {
					this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
							SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
				}
				if (this.successHandler != null) {
					this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
					return;
				}
			}
			catch (AuthenticationException ex) {
				this.logger.debug(LogMessage
						.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
								+ "rejected Authentication returned by RememberMeServices: '%s'; "
								+ "invalidating remember-me token", rememberMeAuth),
						ex);
				this.rememberMeServices.loginFail(request, response);
				onUnsuccessfulAuthentication(request, response, ex);
			}
		}
		chain.doFilter(request, response);
	}

autoLogin 邏輯

    @Override
	public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
		String rememberMeCookie = extractRememberMeCookie(request);
		if (rememberMeCookie == null) {
			return null;
		}
		this.logger.debug("Remember-me cookie detected");
		if (rememberMeCookie.length() == 0) {
			this.logger.debug("Cookie was empty");
			cancelCookie(request, response);
			return null;
		}
		try {
			String[] cookieTokens = decodeCookie(rememberMeCookie); // 解密
			UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
			this.userDetailsChecker.check(user); // 與 DB 進行比對
			this.logger.debug("Remember-me cookie accepted");
			return createSuccessfulAuthentication(request, user);
		}
		catch (CookieTheftException ex) {
			cancelCookie(request, response);
			throw ex;
		}
		catch (UsernameNotFoundException ex) {
			this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
		}
		catch (InvalidCookieException ex) {
			this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
		}
		catch (AccountStatusException ex) {
			this.logger.debug("Invalid UserDetails: " + ex.getMessage());
		}
		catch (RememberMeAuthenticationException ex) {
			this.logger.debug(ex.getMessage());
		}
		cancelCookie(request, response);
		return null;
	}

實作自動登入

建立一張 SQL 表

CREATE TABLE IF NOT EXISTS public.persistent_logins (
    username varchar(64) NOT NULL,
    series varchar(64) NOT NULL primary key,
    token varchar(64) NOT NULL,
    last_used timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

調整 JdbcTokenRepositoryImpl 的數據來源

    @Autowired
    private DataSource dataSource;

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl = new JdbcTokenRepositoryImpl();
        jdbcTokenRepositoryImpl.setDataSource(dataSource);
        return jdbcTokenRepositoryImpl;
    }

configure 新增以下以實現自動登入

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
            .and().rememberMe().tokenRepository(persistentTokenRepository())
            .tokenValiditySeconds(60) // 設置有效時間
            .userDetailsService(userDetailsService)
        ...
    }

修正先前的 login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form action="/user/login" method="post">
        User: <input type="text" name="username">
        <br>
        Password: <input type="password" name="password">
        <br>
        <input type="checkbox" name="remember-me">自動登入 <!-- remember-me 必須這樣設置-->
        <br>
        <input type="submit" value="login">
    </form>
</body>
</html>
測試

先到 login.html 頁面,勾選自動登入,使用開發工具可發現在 cookie 中有了 remember-me 的東西

同樣我們到資料庫中也可以發現其幫我們插入了一筆資料

cch=# \dt
               List of relations
 Schema |       Name        | Type  |  Owner
--------+-------------------+-------+----------
 public | persistent_logins | table | postgres
 public | user              | table | itachi
(2 rows)

cch=# select * from persistent_logins
cch-# ;
 username |          series          |          token           |        last_used
----------+--------------------------+--------------------------+-------------------------
 itachi   | pLVn0k8bFK3NoyU6aBvC+w== | SzdGzAWjl3Q1HbMIh7yn0w== | 2021-10-31 12:50:12.814
(1 row)