2024. 8. 14. 09:25ㆍSpring
서론
Spring Security와 Redis를 이용해 로그인 세션을 유지하고 재로그인 없이 지속적으로 로그인 상태를 관리하기 위한 코드 작성. 토이 프로젝트에서 사용자는 1명으로 고정으로 확장할 예정이 없지만 지속적인 NAS 해킹 시도 및 네이버 아이디 해킹 이슈로 기존 공부했던 Spring Security + Redis를 이용해 로그인 세션 구성하기로 함.
JAVA - Gradle 버전
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
}
java {
sourceCompatibility = '17'
}
dependencies {
//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
//security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
}
사용 파일 목록
- Config Folder
- SecurityConfig // Security Bean 등록
- RedisConfig // Redis Bean 등록
- Handler Folder
- CustomLoginSuccessHandler // 로그인 성공 동작
- CustomLogoutSuccessHandler // 로그아웃 성공 동작
- Controller
- MembersController
- Service
- MembersService
- CustomMemberDetailService // Remember-Me 발급 관련
- RedisTokenService // Redis 토큰 CRUD + 역직렬화
- Repository
- MembesRepository
- Entity
- Members
- Role
- Util
- PersistentRememberMeTokenDeserializer // Redis 토큰 역직렬화 코드
Members, Role Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Members {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(unique = true)
private String memberId;
private String memberPassword;
@Enumerated(EnumType.STRING)
private Role role;
@Builder
public Members(String memberId, String memberPassword, Role role) {
this.memberId = memberId;
this.memberPassword = memberPassword;
this.role = role;
}
}
@Getter
@RequiredArgsConstructor
public enum Role {
USER("USER", "일반유저"),
WAIT("WAIT", "승인 대기"),
ADMIN("ADMIN", "관리자");
private final String key;
private final String title;
}
사용자는 Id, Pw를 이용해 로그인하고 회원가입을 하고 관리자의 승인을 받은 경우에 로그인 성공 처리를 할 예정.
현재 코드는 관리자 승인 코드는 포함되어 있지 않고 DB에 직접 접근해 승인처리함. 필요시 별도 생성.
SecurityConfig, RedisConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
private final RedisTemplate<String, Object> redisTemplate;
private final CustomLoginSuccessHandler customLoginSuccessHandler;
private final CustomLogoutSuccessHandler customLogoutSuccessHandler;
private final CustomMemberDetailService customMemberDetailService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/login", "/signup", "/css/**", "/js/**").permitAll()
.requestMatchers("/").hasRole("WAIT")
.requestMatchers("/automessage/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated());
http.exceptionHandling((except) -> except
.accessDeniedPage("/login"));
http.formLogin((auth) -> auth
.loginPage("/login")
.usernameParameter("memberId")
.passwordParameter("memberPassword")
.loginProcessingUrl("/login")
.successHandler(customLoginSuccessHandler)
.permitAll());
http.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler(customLogoutSuccessHandler)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll());
http.rememberMe((remember) -> remember
.rememberMeParameter("remember")
.tokenValiditySeconds(3 * 24 * 60 * 60) // 3일 동안 유효한 쿠키
.tokenRepository(persistentTokenRepository())
.userDetailsService(customMemberDetailService));
return http.build();
}
// Redis
@Bean
public PersistentTokenRepository persistentTokenRepository() {
return new RedisTokenService(redisTemplate);
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- URL 경로 별 권한 설정
- 해당 경로는 모든 사용자 로그인, 비로그인 사용자 모드 접속 가능
- WAIT 권한 즉 미승인 사용자는 / -> /login URL만 승인
- USER, ADMIN 사용자는 /automessage 접속 가능
- 로그인 except 예외처리
- 에러가 발생한 경우 /login url 반환
- 커스텀 로그인 설정
- login url
- Html에서 사용하는 Id 파라미터 설정 (Defualt username)
- Html에서 사용하는 Pw 파라미터 설정 (Default password)
- login api
- login 성공 핸들러
- 커스텀 로그아웃 설정
- logout url
- logout 성공 핸들러
- 세션 삭제
- 쿠키 삭제
- 재로그인 위한 토큰 RememberMe 생성
- Html에서 사용하는 RembmerMe 파라미터
- 토큰 유효시간 설정
- Redis Repository CRUD 타입 설정
- 생성 조건 핸들러
대부분의 코드에서 csrf를 disable 선언을 해줬는데 이 경우 csrf 공격에 취약해지므로 thymeleaf 문법을 이용해(th:action) csrf를 활성화 해주자
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
}
RedisTokenService, PersistentRememberMeTokenDeserializer(redis Value 역직렬화)
@Service
@Slf4j
public class RedisTokenService implements PersistentTokenRepository {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private static final String REMEMBER_MY_KEY = "rememberMe:token:";
public RedisTokenService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.objectMapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addDeserializer(PersistentRememberMeToken.class, new PersistentRememberMeTokenDeserializer());
this.objectMapper.registerModule(module);
}
private String getRedisKey(String series) {
return REMEMBER_MY_KEY + series;
}
@Override
public void createNewToken(PersistentRememberMeToken token) {
String redisKey = getRedisKey(token.getSeries());
log.info("createKey redisKey {}", redisKey);
log.info("createKey token {}", token.getTokenValue());
try {
ObjectMapper objectMapper = new ObjectMapper();
String tokenJson = objectMapper.writeValueAsString(token);
log.info("createKey tokenJson = {}", tokenJson);
redisTemplate.opsForValue().set(redisKey, tokenJson, 3, TimeUnit.DAYS);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize token to JSON", e);
}
}
@Override
public void updateToken(String series, String tokenValue, Date lastUsed) {
PersistentRememberMeToken token = getTokenForSeries(series);
if (token != null) {
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), series, tokenValue, lastUsed
);
createNewToken(newToken);
}
}
@Override
public PersistentRememberMeToken getTokenForSeries(String seriesId) {
String redisKey = getRedisKey(seriesId);
String tokenJson = (String) redisTemplate.opsForValue().get(redisKey);
log.info("getTokenForSeries {} = {}", redisKey, tokenJson);
try {
return objectMapper.readValue(tokenJson, PersistentRememberMeToken.class);
} catch (Exception e) {
throw new RuntimeException("getTokenForSeries 역직렬화 실패", e);
}
}
@Override
public void removeUserTokens(String username) {
Set<String> keys = redisTemplate.keys(REMEMBER_MY_KEY + "*");
if (keys != null) {
for (String key : keys) {
log.info("key value {}", key);
String tokenJson = (String) redisTemplate.opsForValue().get(key);
if (tokenJson != null) {
try {
PersistentRememberMeToken token = objectMapper.readValue(tokenJson, PersistentRememberMeToken.class);
if (token.getUsername().equals(username)) {
redisTemplate.delete(key);
}
} catch (Exception e) {
throw new RuntimeException("removeUserTokens 역직렬화 실패", e);
}
}
}
}
}
}
여기서 중요 코드로는 커스텀된 역직렬화 코드이다. PersistentRememberMeTokenDeserializer 경우 직렬화 시 @Class를 통해 직렬화 클래스를 할당해 주지만 역직렬화 시 해당 클래스를 인식하지 못하는 문제가 발생해 기본적으로 제공하는 역직렬화 코드 사용 시 오류를 발생하는 문제가 생긴다. 때문에 별도의 PersistentRememberMeTokenDeserializer 이용해 역직렬화를 해주었다. 만약 redis를 사용하는 형식이 고정이라면 redisConfig 직렬화, 역직렬화를 Jackson2JsonRedisSerializer 이용하는 방식으로 클래스 고정을 해주는 방법도 있을 것 같다.
@Slf4j
public class PersistentRememberMeTokenDeserializer extends JsonDeserializer<PersistentRememberMeToken> {
@Override
public PersistentRememberMeToken deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
JsonNode node = p.getCodec().readTree(p);
String username = node.get("username").asText();
String series = node.get("series").asText();
String tokenValue = node.get("tokenValue").asText();
// date 값은 long으로 이뤄짐
JsonNode dateNode = node.get("date");
long date = dateNode.asLong();
return new PersistentRememberMeToken(username, series, tokenValue, new Date(date));
}
}
Date를 json 형식으로 저장할 경우 yy-mm-ss ..~ 형식이 아니라 long 타입의 밀리초 단위로 저장되기 때문에 별도로 타입 변환 과정을 해주어야 한다.
CustomMemberDetailService
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomMemberDetailService implements UserDetailsService {
private final MembersRepository membersRepository;
@Override
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
Members members = membersRepository.findByMemberId(memberId);
if (members == null) {
log.info("사용자를 찾을 수 없습니다.");
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다:" + memberId);
}
log.info("members.getRole() {}", members.getRole());
if (String.valueOf(members.getRole()).equals("WAIT")) {
log.info("미승인 사용자 입니다. ROLE_USER {}", memberId);
throw new UsernameNotFoundException("미승인 사용자 입니다:" + memberId);
}
return new CustomMemberDetails(members); //JSESSIONID 쿠키 저장
}
}
이슈 해결
일반적으로는 사용자를 찾을 수 없는 경우 cookie (remeber-me) 를 발급하는 것을 방지할 때 사용하는데 여기 WAIT role 값을 가진 사용자 (가입 후 미승인) 접근을 막아야 되므로 해당 코드를 추가하였다. 만약 추가되지 않는다면 login에 실패하지만 remember-me 쿠키는 발급되어 무한 리다이렉션 오류가 발생한다.
remeber-me 발급을 거부하는 요청을 처음에는 loginSuccessHandler에서 제어하는 실수를 해 오류 해결에 오래 걸렸다. Security Config 디버깅을 진행해보면 loginSucessHandler가 실행된 후 Remember-Me 쿠키가 발급되는 filter가 실행되어 loginSuccessHandler에서 Remember-Me 토큰을 제거하려고 해도 발급 전이라 로직이 종료되면 재발급 된다. filter chain 순서를 확인해보면 왜 이러한 문제가 발생했는지 알 수 있다.
참고 - secuiry filter 진행 순서
기타 Controller, Service는 기본적인 CRUD 코드이기 때문에 생략하도록 하겠다.