본문 바로가기

Spring Boot

[Spring Security] Multi Tenancy 환경에 Remember Me 적용하기

728x90

Remember-Me 기능은 Spring Security에서 제공하는 자동로그인 기능입니다.

 

하지만 Multi Tenancy 환경에서 User 데이터를 Tenant 별로 관리하고 있다면 자동 로그인 기능이 작동하지 않는 현상이 발생합니다.

 

이를 해결하기 위해 RememberMeService를 직접 구현해 Bean 등록을 해줘야 하고, Remember-Me 토큰을 DB에 저장해 직접 관리해야 합니다.

 

메커니즘

 

토큰 저장 로직

 

최고관리자 Tenant에 Remember-Me 토큰을 저장 해야하고 동시에 User의 Tenant 정보도 저장합니다.

 

User 정보 찾는 로직

 

User가 재방문 했을 때 DB에 저장된 Tenant로 변경 후에 User를 찾아줘야 합니다.

 

 

PersistentLogins 도메인 생성

CREATE TABLE IF NOT EXISTS persistent_logins
(
    username  varchar(38) NOT NULL,
    series    varchar(64) NOT NULL,
    token     varchar(64) NOT NULL,
    lastused  timestamp   NOT NULL,
    schema    varchar(50),

    CONSTRAINT persistent_logins_pkey PRIMARY KEY (series)
);
@Table(schema = "\"master\"")
@Entity(name = "persistent_logins")
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class PersistentLogin implements Serializable {

  @Id
  @EqualsAndHashCode.Include
  private String series;
  private String username;
  private String token;
  private Date lastUsed;
  private String schema;

  public PersistentLogin(PersistentRememberMeToken token) {
    this.series = token.getSeries();
    this.username = token.getUsername();
    this.token = token.getTokenValue();
    this.lastUsed = token.getDate();
    // 현재 로그인한 유저의 schema를 저장.
    this.schema = UserUtil.getLoggedUser().getSchema();
  }

  public void updateToken(String tokenValue, Date lastUsed) {
    this.token = tokenValue;
    this.lastUsed = lastUsed;
  }
}

 

PersistentLogin 도메인은 'master' Tenant에 관리 되어야 합니다.

 

토큰을 DB에서 직접 관리하려면 도메인이 필요한데, 정해진 형식대로 만들어 줘야 합니다.

 

여기에 Tenant를 구별하기 위한 schema 라는 컬럼을 추가했습니다.

 

 

JpaRepository 생성

@Repository
public interface PersistentLoginRepository extends JpaRepository<PersistentLogin, String> {

  PersistentLogin findBySeries(String series);

  List<PersistentLogin> findByUsername(String username);
}

 

PersistentTokenRepository 구현

@Repository
@RequiredArgsConstructor
public class JpaPersistentTokenRepository implements PersistentTokenRepository {

  @NonNull
  private final PersistentLoginRepository loginRepository;

  // 토큰 저장하는 메소드
  @Override
  public void createNewToken(PersistentRememberMeToken token) {
    TenantContext.setCurrentTenant("master");
    loginRepository.save(new PersistentLogin(token));
    TenantContext.clear();
  }

  // 토큰 변경하는 메소드
  @Override
  public void updateToken(String series, String tokenValue, Date lastUsed) {
    TenantContext.setCurrentTenant("master");
    PersistentLogin persistentLogin = loginRepository.findBySeries(series);
    if (persistentLogin != null) {
      persistentLogin.updateToken(tokenValue, lastUsed);
      loginRepository.save(persistentLogin);
    }
    TenantContext.clear();
  }


  // DB에 저장된 도메인을 찾아오는 메소드
  public PersistentLogin getSchemaSeries(String seriesId) {
    TenantContext.setCurrentTenant("master");
    return loginRepository.findBySeries(seriesId);
  }

  // DB에 저장된 토큰을 찾아오는 메소드
  @Override
  public PersistentRememberMeToken getTokenForSeries(String seriesId) {
    TenantContext.setCurrentTenant("master");
    PersistentLogin persistentLogin = loginRepository.findBySeries(seriesId);

    return new PersistentRememberMeToken(
        persistentLogin.getUsername(),
        persistentLogin.getSeries(),
        persistentLogin.getToken(),
        persistentLogin.getLastUsed()
    );
  }

  // 세션이 종료될 경우 토큰 제거하는 메소드
  @Override
  public void removeUserTokens(String username) {
    TenantContext.setCurrentTenant("master");
    loginRepository.deleteAllInBatch(loginRepository.findByUsername(username));
    TenantContext.clear();
  }
}

 

PersistentTokenRepository를 Override하는 구현체입니다.

 

DB에 저장된 토큰을 관리할 때 'master' Tenant에서 관리해야 하므로 CRUD 전에 master schema로 변경 후 작업하도록 합니다.

 

UserDetailsService 구현

 

@Service
@RequiredArgsConstructor
public class UserLoadService implements UserDetailsService {

  @NonNull
  private final MasterUserRepository materUserRepository;
  @NonNull
  private final UserRepository userRepository;

  public UserDetails loadUserByUsername(String username, String schema) throws UsernameNotFoundException {
    UserInfo user;
    if (StringUtils.isNotBlank(schema) && schema.equals("master")) {
      user = materUserRepository.findByUsername(username).orElseThrow(RememberMeException::new);
    } else {
      TenantContext.setCurrentTenant(schema);
      user = userRepository.findByUsername(username).orElseThrow(RememberMeException::new);
    }
    return (UserDetails) user;
  }

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return null;
  }
}

 

Security Config에 넣어줘야 하는 UserDetailsService를 구현한 Service입니다.

 

Override 하는 함수는 사용하지 않고 직접 만들어서 사용을 해야합니다.

 

자동로그인이 원활하게 작동되면  RememberMeService가 loadUserByUsername을 호출해 User를 찾아오게 되는데, 그때 schema 정보를 같이 넘기고 user를 찾아오기 전에 Tenant를 변경해줘야 합니다.

 

AbstractRememberMeServices 구현

public class RememberMeService extends AbstractRememberMeServices {
  private JpaPersistentTokenRepository tokenRepository;
  private UserLoadService userLoadService;
  private SecureRandom random;

  public RememberMeService(String key, UserLoadService userLoadService,
      JpaPersistentTokenRepository tokenRepository) {
    super(key, userLoadService);
    this.userLoadService = userLoadService;
    this.tokenRepository = tokenRepository;
    random = new SecureRandom();
  }

  // 자동 로그인을 체크한 후 로그인에 성공하게 되면 실행되는 메소드
  @Override
  protected void onLoginSuccess(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Authentication authentication) {

    String username = authentication.getName();
    String newSeriesValue = generateTokenValue();
    String newTokenValue = generateTokenValue();

    PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username,
        newSeriesValue, newTokenValue, new Date());

    try {
      tokenRepository.createNewToken(persistentToken);
      String[] rawCookieValues = new String[]{newSeriesValue, newTokenValue};
      super.setCookie(rawCookieValues, TOKEN_VALIDITY_SECOND, httpServletRequest,
          httpServletResponse);
    } catch (Exception e) {
      throw new RememberMeException();
    }
  }

  // 자동 로그인 토큰이 존재할 때 자동으로 실행되는 메소드
  @Override
  protected UserDetails processAutoLoginCookie(String[] cookieTokens,
      HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)
      throws RememberMeAuthenticationException, UsernameNotFoundException {
    if (cookieTokens.length != 2) {
      throw new RememberMeException();
    } else {
      String presentedSeries = cookieTokens[0];
      String presentedToken = cookieTokens[1];

      PersistentLogin persistentLogin = tokenRepository.getSchemaSeries(presentedSeries);
      if (persistentLogin == null) {
        super.cancelCookie(httpServletRequest,httpServletResponse);
        throw new RememberMeException();
      }
      String schema = persistentLogin.getSchema();

      PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);

      if (token == null) {
        throw new RememberMeException();
      } else if (!presentedToken.equals(token.getTokenValue())) {
        tokenRepository.removeUserTokens(token.getUsername());
        throw new RememberMeException();
      } else if (token.getDate().getTime() + (long) TOKEN_VALIDITY_SECOND * 1000L < System.currentTimeMillis()) {
        throw new RememberMeException();
      } else {
        PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(),
            token.getSeries(), generateTokenValue(), new Date());
        try {
          tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
              newToken.getDate());
          String[] rawCookieValues = new String[]{newToken.getSeries(), newToken.getTokenValue()};
          super.setCookie(rawCookieValues, TOKEN_VALIDITY_SECOND, httpServletRequest,
              httpServletResponse);
        } catch (Exception e) {
          throw new RememberMeException();
        }

        return userLoadService.loadUserByUsername(token.getUsername(), schema);
      }
    }
  }

  // 로그아웃시 토큰을 제거하는 메소드
  @Override
  public void logout(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) {
    super.logout(request, response, authentication);
    if (authentication != null) {
      tokenRepository.removeUserTokens(authentication.getName());
    }
  }

  private String generateTokenValue() {
    byte[] newToken = new byte[16];
    random.nextBytes(newToken);
    return new String(Base64.getEncoder().encode(newToken));
  }
}

 

RememberMeExceiption은 AccountStatusException를 상속받은 커스텀 Exception입니다.

 

 

onLoginSuccess 를 통과하면 토큰이 생성되고 DB에 저장됩니다.

 

그 후 자동로그인을 유저가 다시 돌아오게 되면 processAutoLoginCookie 가 실행되는데 DB에 저장된 토큰을 쿠키 정보로 불러오고 토큰 정보를 검증 한 후에 User를 찾아 반환하게 됩니다.

 

로그아웃 버튼을 눌르면 logout이 실행되고 가진 쿠키 정보와 해당 User의 username으로 DB에 저장된 모든 토큰을 제거합니다.

 

 

@NonNull
private final UserLoadService userLoadService;
@NonNull
private final JpaPersistentTokenRepository jpaPersistentTokenRepository;

...

@Bean
public RememberMeService rememberMeService() {
  return new RememberMeService(REMEMBER_ME_KEY, userLoadService, jpaPersistentTokenRepository);
}

...
@Override
protected void configure(HttpSecurity http) throws Exception {
	http.rememberMe()
    	.rememberMeParameter(REMEMBER_ME_KEY)
    	.tokenValiditySeconds(TOKEN_VALIDITY_SECOND)
    	.alwaysRemember(false)
    	.rememberMeServices(rememberMeService())
    	.userDetailsService(userLoadService);
}

 

만들어준 RememberMeService를 Bean 등록 해주고 configure 등록해주면 완성입니다.

 

 

 

참조 :

https://shirohoo.github.io/spring/spring-security/2021-10-08-remember-me/

 

Remember-Me

주로 자동로그인 등에 사용되는 Remember-Me 기능에 대해 알아봅시다.

shirohoo.github.io

https://codevang.tistory.com/280

 

스프링 Security_Remember-me 커스터마이징 [2/3]

- Develop OS : Windows10 Ent, 64bit - WEB/WAS Server : Tomcat v9.0 - DBMS : MySQL 5.7.29 for Linux (Docker) - Language : JAVA 1.8 (JDK 1.8) - Framwork : Spring 3.1.1 Release - Build Tool : Maven 3.6..

codevang.tistory.com

 

 

 


박준호 / 선임연구원

Junho Park / 서비스R&D팀

 

 

junho@userinsight.co.kr

728x90