Spring Security와 Redis 이용한 로그인 세션 유지

2024. 8. 14. 09:25Spring

 

서론

 

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();
    }

}

 

  1. URL 경로 별 권한 설정
    1. 해당 경로는 모든 사용자 로그인, 비로그인 사용자 모드 접속 가능
    2. WAIT 권한 즉 미승인 사용자는 / -> /login URL만 승인 
    3. USER, ADMIN 사용자는 /automessage 접속 가능
  2. 로그인 except 예외처리 
    1. 에러가 발생한 경우 /login url 반환
  3. 커스텀 로그인 설정
    1. login url
    2. Html에서 사용하는 Id 파라미터 설정 (Defualt username)
    3. Html에서 사용하는 Pw 파라미터 설정 (Default password)
    4. login api
    5. login 성공 핸들러
  4. 커스텀 로그아웃 설정
    1. logout url
    2. logout 성공 핸들러
    3. 세션 삭제
    4. 쿠키 삭제
  5. 재로그인 위한 토큰 RememberMe 생성
    1. Html에서 사용하는 RembmerMe 파라미터
    2. 토큰 유효시간 설정
    3. Redis Repository CRUD 타입 설정
    4. 생성 조건 핸들러

대부분의 코드에서 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 코드이기 때문에 생략하도록 하겠다.