Tagbangers Blog

Spring Security でマルチテナントしたときの Remember-me 認証対応

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

おわり。