下記のログイン画面のように、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;
}
}
おわり。
