IAS - Spring Security 도입 - login

IKNOW·2024년 2월 4일
0

See space

목록 보기
1/9
post-thumbnail

현재 진행중인 AUTHENTICATION SERVER에서 단순한 기능들만을 구현하고 있는데도, JWT 파싱, REDIS 조회, DB 조회등 반복적인 코드 작업이 계속 해서 일어나고 있기 때문에 현재 프로젝트에 Spring Security 의 필터기능을 사용하기로 결정하였다.

    @Override
    public ResponseEntity<AccountDTO> getMyInfo(String token) {
        //access token을 파싱해서 jwt에 들어 있는 정보를 가져온다.
        Map<String, Object> values = jwtService.parseToken(token);
        if (values == null) {
            return ResponseEntity.badRequest().build();
        }
        //jwt에 들어있는 accountId를 사용해서 해당 id-accessToken이 redis에 저장되어 있는지 확인한다.
        Long accountId = ((Integer) values.get("accountId")).longValue();

        Optional<AccessToken> validToken = tokenService.findAccessTokenById(accountId);
        if (validToken.isEmpty() || !validToken.get().getJwt().equals(token.substring(7))) {
            return ResponseEntity.badRequest().build();
        }
		//accountId를 사용해서 DB에 저장되어 있는 엔티티를 가져온다.
        Optional<Account> maybeAccount = accountRepository.findById(accountId);
        if (maybeAccount.isEmpty()) {
            return ResponseEntity.badRequest().build();
        }
        //실질적으로 사용하고자하는 로직
        Account account = maybeAccount.get();
        return ResponseEntity.ok(AccountDTO.builder()
                .id(account.getId())
                .email(account.getEmail())
                .nickname(account.getNickname())
                .build());
    }

위의 주석으로 코멘트 되어 있는 세 개의 검증과정은 스프링에서 요청받은 token이 실제 account의 토큰인지 확인하는 과정으로 account 검증이 필요한 작업에서는 필연적으로 반복하게 된다.

실직적으로 수행하는 로직은 return 이 포함된 단 한줄인데 인가 작업을 위해 더 많은 코드가 작성된다.

이러한 반복되는 로직은 [filter intercepter 그리고 AOP]에서 언급 했던것과 같이 filter, intercepter, AOP 세가지 방식으로 수행할 수 있지만, Spring Security가 이전 프로젝트와 코프링 프로젝트에서 사용해 본 경험이 있기 때문에 크게 어렵지 않게 구현 할 수 있으리라고 생각되기 때문에 사용하기로 결정하였다.

또한 충분히 인가 작업이 귀찮아 질 때쯤 시작해도 되겠지만, 그때 시큐리티를 사용한 인가 작업을 수정하게 되면, 거의 모든 코드가 수정이 일어나야 하는 대규모 작업이 될 것으로 예상되기 때문에 미리미리 적용 하고자 결정하였다.

처음부터 Spring Security를 사용하지 않은 이유

내가 처음으로 진행한 프로젝트 I’M FINE! APPLE에서는 Security를 사용하지 않았고, 두번째로 진행한 GET-MOIM에서는 처음부터 Security를 사용해서 프로젝트를 진행하였다. 각 프로젝트에선 해당 방식의 장단점을 잘 알 수 있었지만, 이번 프로젝트에서는 Spring Security를 사용하지 않다가 사용하는 방식으로 넘어감에 따라 변화하는 서비스 코드의 변경사항등을 더 자세하게 느껴보고자 Security를 처음엔 적용하지 않고 먼저 Account Domain을 작성한 이후 Security를 적용하기로 결정하였었다.

의존성 추가

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'
}

SecurityConfig 초기 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        
            http.cors(cors-> cors.disable());
            http.csrf(csrf ->csrf.disable());
            http.formLogin(formLogin -> formLogin.disable());

            return http.build();
    }
}

먼저 진행하고 있는 프로젝트의 스프링부트 애플리케이션은 jwt타입의 토큰을 사용하고 restful api로 진행중이기 때문에 csrf공격에 대해 면역이 되어 있어 불필요한 csrf 필터를 제거한다.

또한 formLogin 또한 외부에서 로그인을 진행하기 때문에 비활성화, cors 같은 경우는 WebMvcConfigurer를 통해 따로 설정해 주었기 때문에 비활성화 하였다. 원할 경우 CORS같은 경우 Spring Security에서 활성화 할 수 도 있다.

authenticationManager 설정

public class SecurityConfig {
		@Bean
		public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
				...
				http.authenticationManger(buildCustomAuthenticationManager(http));
				http.addFilterBefore(loginFilter(buildCustomAuthenticationManager(http)), UsernamePasswordAuthenticationFilter.class);
				...
		}
		
		@Bean
		public AuthenticationManager buildCustomAuthenticationManager(HttpSecurity http) throws Exception {
		    AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
		    authenticationManagerBuilder.userDetailsService(customUserDetailsService).passwordEncoder(passwordEncoder());
		    return authenticationManagerBuilder.build();
		}
		
		@Bean
		public LoginFilter loginFilter(AuthenticationManager authenticationManager){
		    LoginFilter loginFilter = new LoginFilter("/account/login");
		    loginFilter.setAuthenticationManager(authenticationManager);
		    loginFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler(jwtService));
		    loginFilter.setAuthenticationFailureHandler(new LoginFailureHandler());
		    return loginFilter;
		}
		
		@Bean
		public BCryptPasswordEncoder passwordEncoder() {
		    return new BCryptPasswordEncoder();
		}
}

다음으로는 DB에 저장된 계정을 조회하는 UserDetailsService를 구현한 CustomUserDetailsService를 AuthenticationManager로 사용하고 실질적으로 login시 실행하게 되는 loginFilter를 filter-chain 넣는다.

로그인시 가장 중요한 로직이 들어가는 CustomUserDetailsService와 LoginSuccessHandler는 다음과 같다.

혹시 그 외의 LoginFailureHandler, LoginFilter가 궁금하다면 https://github.com/iknowca/iknow-authentication-server에서 확인하길 바란다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    final AccountRepository accountRepository;
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Optional<Account> maybeAccount = accountRepository.findByEmail(email);
        if(maybeAccount.isEmpty()) {
            throw new UsernameNotFoundException("There are no account matching the email: "+email);
        }
        Account account = maybeAccount.get();

        return CustomUserDetails.builder()
                .username(account.getEmail())
                .password(account.getPassword())
                .account(account)
                .authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_"+"USER")))
                .isEnabled(true)
                .isCredentialsNonExpired(true)
                .isAccountNonLocked(true)
                .isAccountNonExpired(true)
                .build();
    }
}
@RequiredArgsConstructor
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Account account = ((CustomUserDetails)authentication.getPrincipal()).getAccount();

        String accessToken = jwtService.generateAccessToken(account);
        String refreshToken = jwtService.generateRefreshToken(account);

        response.setContentType("application/json");
        PrintWriter out = response.getWriter();
        Gson gson = new Gson();
				String jsonStr = gson.toJson(Map.of("accessToken", "Bearer " + accessToken, "refreshToken", "Bearer" + refreshToken));
        
				out.write(jsonStr);
        out.flush();
        out.close();
    }
}

UserDetailsService interface에는 loadUserByUsername한개의 메서드만을 갖고 있는데 이 메서드에서는 UserDetails의 username필드에 해당하는 email을 사용해서 db에서 동일한 email을 갖고 잇는 계정을 찾는다.

다음으로 CustomUserDetails 객체를 생성하여 반환한다.

LoginSuccessHandler의 경우는 LoginFilter에서 로그인에 성공하였다면 넘어오는 부분이다.

전달받은 authentication 객체에서 account 객체를 꺼내 accessToken, refreshToken을 발행한 이후 response에 담아 반환한다. 이것이 CustomUserDetails에 account 필드를 추가한 이유이다. 이렇게 하지 않으면, LoginSuccessHandler에서 username을 사용해서 db에서 다시 한번 조회를 해야 할 것이다. 이에 관한 ISSUE(ThreadLocal)가 한 가지 존재하는데 글도 너무 길어졌고, 주제를 약간 벗어나서 다음에 적도록 하겠다.

postman을 사용해서 테스트 해본 결과 이전과 같이 정상적으로 accessToken과 refreshToken이 발행되는 것을 확인 할 수 있었다.

profile
조금씩,하지만,자주

0개의 댓글

관련 채용 정보