Remember-Me 기능은 Spring Security에서 제공하는 자동로그인 기능입니다.
하지만 Multi Tenancy 환경에서 User 데이터를 Tenant 별로 관리하고 있다면 자동 로그인 기능이 작동하지 않는 현상이 발생합니다.
이를 해결하기 위해 RememberMeService를 직접 구현해 Bean 등록을 해줘야 하고, Remember-Me 토큰을 DB에 저장해 직접 관리해야 합니다.
메커니즘
최고관리자 Tenant에 Remember-Me 토큰을 저장 해야하고 동시에 User의 Tenant 정보도 저장합니다.
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/
https://codevang.tistory.com/280
박준호 / 선임연구원
Junho Park / 서비스R&D팀
'Spring Boot' 카테고리의 다른 글
[Java] 모두 null 인지, null이 하나라도 존재하는 지 체크 (ObjectUtils) (0) | 2022.11.04 |
---|---|
[JPA] Entity Column에 Map 사용하기 (0) | 2022.10.06 |
[Spring Security] Run-As로 임시 권한 부여하기 (0) | 2022.08.19 |
hikariCP 커넥션 누수 탐지 및 QueryDsl의 transform 커넥션 누수 이슈 해결 (0) | 2022.08.12 |
[Spring Boot] Optional의 orElse(), orElseGet() 알고 쓰기 (0) | 2022.08.11 |