下記のログイン画面のように、ID と それ以外のパラメータを用いて認証した場合の Spring Security の Remember-Me 認証方法をご紹介。
ソースコードは GitHub (https://github.com/tagbangers/spring-best-practice...) で公開しています。
Spring Security は Remember-Me 認証の実装の1つとして、PersistentTokenBasedRememberMeServices クラスを提供している。PersistentTokenBasedRememberMeServices はログイン情報をデータベースに保存して Remember-Me 認証時に使用している。ただし、ログインIDのみがデータベースに保存されるので、今回のようにユーザーを特定するためにログインID以外のパラメータがある場合は対応できない。
まずは PersistentTokenBasedRememberMeServices の処理を確認する。
登場するクラスは以下のとおり。
RememberMeAuthenticationFilter | Remember-Me 認証を検出するフィルター |
PersistentTokenBasedRememberMeServices | Remember-Me 認証の実装 http://jaspan.com/improved_persistent_login_cookie... |
JdbcTokenRepositoryImpl |
認証トークンのデータベースアクセスレポジトリー |
UserDetailsService | ユーザー情報の読込みインターフェイス |
AuthenticationManager | 認証処理を行う |
RememberMeAuthenticationProvider | Remember-Me 認証トークンを検証する |
今回は、PersistentTokenBasedRememberMeServices にかわるマルチテナント対応版として、MultiTenantRememberMeServices を実装してみます。
UserDetailsService の実装は、リクエストパラメータ、もしくはアトリビュートから tenantId を取得するようにする。
@Component public class AuthorizedUserDetailsService implements UserDetailsService { public static final String TENANT_ID_ATTRIBUTE = AuthorizedUserDetailsService.class.getCanonicalName() + ".tenantId"; public static final String TENANT_ID_PARAM_NAME = "tenantId"; @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String tenantId = ServletRequestUtils.getStringParameter(request, TENANT_ID_PARAM_NAME, (String) request.getAttribute(TENANT_ID_ATTRIBUTE)); if (tenantId == null) { throw new UsernameNotFoundException("tenantId is Null"); } User user = userRepository.findOneByTenantIdAndUsername(tenantId, username); if (user != null) { return new AuthorizedUserDetails(user); } throw new UsernameNotFoundException(username); } }
PersistentRememberMeToken を拡張して、tenantId を追加する。
public class MultiTenantRememberMeToken extends PersistentRememberMeToken { private final String tenantId; public MultiTenantRememberMeToken(String tenantId, String username, String series, String tokenValue, Date date) { super(username, series, tokenValue, date); this.tenantId = tenantId; } public String getTenantId() { return tenantId; } }
データベースへのアクセスロジックを実装する。
public class MultiTenantTokenRepository extends JdbcDaoSupport { /** Default SQL for creating the database table to store the tokens */ public static final String CREATE_TABLE_SQL = "create table persistent_logins (tenant_id varchar(64) not null, username varchar(64) not null, series varchar(64) primary key, " + "token varchar(64) not null, last_used timestamp not null)"; /** The default SQL used by the <tt>getTokenBySeries</tt> query */ public static final String DEF_TOKEN_BY_SERIES_SQL = "select tenant_id,username,series,token,last_used from persistent_logins where series = ?"; /** The default SQL used by <tt>createNewToken</tt> */ public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (tenant_id, username, series, token, last_used) values(?,?,?,?,?)"; /** The default SQL used by <tt>updateToken</tt> */ public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?"; /** The default SQL used by <tt>removeUserTokens</tt> */ public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ? and tenant_id = ?"; private String tokensBySeriesSql = DEF_TOKEN_BY_SERIES_SQL; private String insertTokenSql = DEF_INSERT_TOKEN_SQL; private String updateTokenSql = DEF_UPDATE_TOKEN_SQL; private String removeUserTokensSql = DEF_REMOVE_USER_TOKENS_SQL; private boolean createTableOnStartup; @Override protected void initDao() { if (createTableOnStartup) { getJdbcTemplate().execute(CREATE_TABLE_SQL); } } public void createNewToken(MultiTenantRememberMeToken token) { getJdbcTemplate().update(insertTokenSql, token.getTenantId(), token.getUsername(), token.getSeries(), token.getTokenValue(), token.getDate()); } public void updateToken(String series, String tokenValue, Date lastUsed) { getJdbcTemplate().update(updateTokenSql, tokenValue, lastUsed, series); } public MultiTenantRememberMeToken getTokenForSeries(String seriesId) { try { return getJdbcTemplate().queryForObject(tokensBySeriesSql, new RowMapper<MultiTenantRememberMeToken>() { public MultiTenantRememberMeToken mapRow(ResultSet rs, int rowNum) throws SQLException { return new MultiTenantRememberMeToken( rs.getString(1), // tenant_id rs.getString(2), // username rs.getString(3), // series rs.getString(4), // token rs.getTimestamp(5)); // last_used } }, seriesId); } catch (EmptyResultDataAccessException zeroResults) { if (logger.isDebugEnabled()) { logger.debug("Querying token for series '" + seriesId + "' returned no results.", zeroResults); } } catch (IncorrectResultSizeDataAccessException moreThanOne) { logger.error("Querying token for series '" + seriesId + "' returned more than one value. Series" + " should be unique"); } catch (DataAccessException e) { logger.error("Failed to load token for series " + seriesId, e); } return null; } public void removeUserTokens(String username, String tenantId) { getJdbcTemplate().update(removeUserTokensSql, username, tenantId); } public void setCreateTableOnStartup(boolean createTableOnStartup) { this.createTableOnStartup = createTableOnStartup; } }
AbstractRememberMeServices 拡張して Remember-Me 認証を実装する。
public class MultiTenantRememberMeServices extends AbstractRememberMeServices { private MultiTenantTokenRepository tokenRepository = new MultiTenantTokenRepository(); private SecureRandom random; public static final int DEFAULT_SERIES_LENGTH = 16; public static final int DEFAULT_TOKEN_LENGTH = 16; private int seriesLength = DEFAULT_SERIES_LENGTH; private int tokenLength = DEFAULT_TOKEN_LENGTH; public MultiTenantRememberMeServices( String key, UserDetailsService userDetailsService, MultiTenantTokenRepository tokenRepository) { super(key, userDetailsService); random = new SecureRandom(); this.tokenRepository = tokenRepository; } @Override 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) + "'"); } final String presentedSeries = cookieTokens[0]; final String presentedToken = cookieTokens[1]; MultiTenantRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries); if (token == null) { // No series match, so we can't authenticate using this cookie throw new CookieTheftException( "No persistent token found for series id: " + presentedSeries); } // We have a match for this user/series combination if (!presentedToken.equals(token.getTokenValue())) { // Token doesn't match series value. Delete all logins for this user and throw // an exception to warn them. tokenRepository.removeUserTokens(token.getUsername(), token.getTenantId()); throw new CookieTheftException( messages.getMessage( "PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.")); } if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System .currentTimeMillis()) { throw new RememberMeAuthenticationException("Remember-me login has expired"); } // Token also matches, so login is valid. Update the token value, keeping the // *same* series number. if (logger.isDebugEnabled()) { logger.debug("Refreshing persistent login token for " + "tenantId '" + token.getTenantId() + "', " + "user '" + token.getUsername() + "', " + "series '" + token.getSeries() + "'"); } MultiTenantRememberMeToken newToken = new MultiTenantRememberMeToken( token.getTenantId(), token.getUsername(), token.getSeries(), generateTokenData(), new Date()); try { tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate()); addCookie(newToken, request, response); } catch (Exception e) { logger.error("Failed to update token: ", e); throw new RememberMeAuthenticationException( "Autologin failed due to data access problem"); } request.setAttribute(AuthorizedUserDetailsService.TENANT_ID_ATTRIBUTE, newToken.getTenantId()); return getUserDetailsService().loadUserByUsername(token.getUsername()); } @Override protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = successfulAuthentication.getName(); logger.debug("Creating new persistent login for user " + username); MultiTenantRememberMeToken token = new MultiTenantRememberMeToken( extractTenantId(successfulAuthentication), username, generateSeriesData(), generateTokenData(), new Date()); try { tokenRepository.createNewToken(token); addCookie(token, request, response); } catch (Exception e) { logger.error("Failed to save persistent token ", e); } } @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { super.logout(request, response, authentication); if (authentication != null) { tokenRepository.removeUserTokens(authentication.getName(), extractTenantId(authentication)); } } protected String extractTenantId(Authentication authentication) { AuthorizedUserDetails userDetails = (AuthorizedUserDetails) authentication.getPrincipal(); return userDetails.getTenantId(); } protected String generateSeriesData() { byte[] newSeries = new byte[seriesLength]; random.nextBytes(newSeries); return new String(Base64.encode(newSeries)); } protected String generateTokenData() { byte[] newToken = new byte[tokenLength]; random.nextBytes(newToken); return new String(Base64.encode(newToken)); } private void addCookie(MultiTenantRememberMeToken token, HttpServletRequest request, HttpServletResponse response) { setCookie(new String[] { token.getSeries(), token.getTokenValue() }, getTokenValiditySeconds(), request, response); } public void setSeriesLength(int seriesLength) { this.seriesLength = seriesLength; } public void setTokenLength(int tokenLength) { this.tokenLength = tokenLength; } @Override public void setTokenValiditySeconds(int tokenValiditySeconds) { Assert.isTrue(tokenValiditySeconds > 0, "tokenValiditySeconds must be positive for this implementation"); super.setTokenValiditySeconds(tokenValiditySeconds); } }
設定を記述する。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private DataSource dataSource; @Autowired public void configureGlobal(UserDetailsService userDetailsService, AuthenticationManagerBuilder auth) throws Exception { // @formatter:off auth .userDetailsService(userDetailsService); // @formatter:on } @Override public void configure(WebSecurity web) throws Exception { // @formatter:off web.ignoring() .antMatchers("/webjars/**"); // @formatter:on } @Override protected void configure(HttpSecurity http) throws Exception { String rememberMeKey = UUID.randomUUID().toString(); //@formatter:off http .authorizeRequests() .antMatchers("/login**").permitAll() .anyRequest() .authenticated() .and() .formLogin() .loginPage("/login") .failureUrl("/login?error").permitAll() .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")) .and() .rememberMe() .key(rememberMeKey) .rememberMeServices(new MultiTenantRememberMeServices(rememberMeKey, userDetailsService(), multiTenantTokenRepository())) .and() .csrf().disable() .headers().frameOptions().sameOrigin(); //@formatter:on } @Bean public MultiTenantTokenRepository multiTenantTokenRepository() { MultiTenantTokenRepository repository = new MultiTenantTokenRepository(); repository.setDataSource(dataSource); return repository; } }
おわり。