Develop/Java & Spring

[Spring Boot] 게시판 프로젝트 v2.0 - JWT를 이용한 회원가입 및 로그인 구현 #6 Config, Filter, DTO, Contoller

jjh0119 2025. 3. 26. 13:25

JWT 프로젝트도 이제 마무리에 다다랐다.
Service를 구현한 것으로 굵직한 로직은 마무리 했기 때문에 이제는 실질적으로 클라이언트와 통신하기 위한 설정들을 해주면 된다.


1. Config

com.security_board.config.DataConfig

package com.security_board.config;

@Configuration
@RequiredArgsConstructor
@EnableJpaRepositories(basePackages = { "com.security_board.board.repository",
		"com.security_board.security.member.repository" })
@EnableRedisRepositories(basePackages = "com.security_board.security.jwt.repository")
public class DataConfig {
	@Value("${spring.data.redis.host}")
	private String host;

	@Value("${spring.data.redis.port}")
	private int port;

	@Bean
	RedisConnectionFactory redisConnectionFactory() {
		return new LettuceConnectionFactory(host, port);
	}
}

Redis를 사용하기 위한 config파일이다.
@EnableJpaRepositories로 JPA를 사용하는 repository를 빈으로 등록해주고 @EnableRedisRepositories로 Redis를 사용하는 repository를 등록해준다.
Redis와 JPA를 함께 사용할 때 JPA를 사용하는 repository와 Redis를 사용하는 repository를 명시적으로 지정해주는 어노테이션인데 꼭 해야만 하는 건 아니지만 로그에 명시해달라는 로그가 계속 뜨기 때문에 따로 설정을 해주면 좋다.
구현된 메서드는 redisConnectionFactory() 하나인데 yml에 저장된 host와 port 값을 가져와서 LettuceConnectionFactory를 생성하여 Redis와의 연결을 설정하고 bean으로 등록한다.

com.security_board.config.PasswordEncoderConfig

package com.security_board.config;

@Configuration
public class PasswordEncoderConfig {

    // 비밀번호 암호화를 위한 Bean 생성
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Spring Security에서 제공하는 passwordEncoder() 메서드를 호출하여 생성된 PasswordEncoder 구현체 BCryptPasswordEncoder를 빈으로 등록하기 위한 config파일로 원래 SecurtityConfig 파일 안에 들어있었으나 JwtService 클래스와의 순환참조 이슈로 따로 분리해 코드를 작성했다.

com.security_board.config.SecurityConfig

package com.security_board.config;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableWebMvc
public class SecurityConfig {
	public static final String PERMITTED_URI[] = {"/api/auth/**", "/signup"};
    private static final String PERMITTED_ROLES[] = {"USER", "ADMIN"};
    private final JwtService jwtService;
    private final MemberService memberService;
    private final FormLoginSuccessHandler formLoginSuccessHandler;
    private final FormLoginFailHandler formLoginFailHandler;
    
	@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration corsConfiguration = new CorsConfiguration();
		
		corsConfiguration.addAllowedOrigin("http://localhost:3000");
		corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"));
		corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type"));
		corsConfiguration.setAllowCredentials(true);

		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", corsConfiguration); // 모든 경로에 대해서 CORS 설정을 적용

		return source;
	}


	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()));

        http.csrf(AbstractHttpConfigurer::disable);
        
        http.httpBasic(HttpBasicConfigurer::disable);

        http.formLogin(AbstractHttpConfigurer::disable);
        
		http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
			httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.NEVER);
		});

		http.authorizeHttpRequests(request -> request
				.requestMatchers(PERMITTED_URI).permitAll()
			    .requestMatchers("/api/admin/**").hasRole("ADMIN")
			    .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
			    .anyRequest().hasAnyRole(PERMITTED_ROLES)
			);

		http.addFilterBefore(new JwtAuthenticationFilter(jwtService, memberService), UsernamePasswordAuthenticationFilter.class);		
		return http.build();
	}
}

config 패키지에서 가장 중요한 역할, 클래스명처럼 Security의 설정에 관한 클래스다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableWebMvc

@configuration : 이 클래스가 스프링의 설정 클래스로 사용된다는 의미. 스프링 컨테이너에 의해 빈으로 관리됨
@EnableWebSecurity : 스프링 시큐리티를 활성화. 이 어노테이션을 통해 스프링 시큐리티 필터가 애플리케이션에 적용됨.
@EnableWebMv : 스프링 MVC를 활성화하여 웹 애플리케이션에서 컨트롤러, 뷰, 리졸버 등을 사용할 수 있도록 설정

	@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration corsConfiguration = new CorsConfiguration();
		
		corsConfiguration.addAllowedOrigin("http://localhost:3000");
		corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"));
		corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type"));
		corsConfiguration.setAllowCredentials(true);

		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", corsConfiguration);

		return source;
	}

CORS 설정을 하는 메서드. CORS란 다른 도메인에서 오는 HTTP 요청을 허용하는 규칙을 의미하는데 이 메서드에서는 "http://localhost:3000" 즉 React앱의 요청을 허용하며 GET, POST, PUT, DELETE 등의 HTTP 메서드와 추가로 Header로는 "Authorization", "Cache-Control", "Content-Type"을 허용한다는 뜻이다.
토큰 발급 이후에 인증이 필요한 API 요청은 모두 토큰을 쿠키를 담아서 보내야 하기 때문에corsConfiguration.setAllowCredentials(true)를 통해 쿠키의 전송을 허용해준다.

public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()));

        http.csrf(AbstractHttpConfigurer::disable);
        
        http.httpBasic(HttpBasicConfigurer::disable);

        http.formLogin(AbstractHttpConfigurer::disable);
        
		http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
			httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.NEVER);
		});

cors(httpSecurityCorsConfigurer -> ...) : 위에서 생성한 CORS 설정 객체를 필터체인에 추가.
http.csrf(AbstractHttpConfigurer::disable): CSRF 비활성화. REST API를 사용하기도 하고 JWT를 통한 인증을 사용하기 때문에 CSRF가 필요 없음.
http.httpBasic(HttpBasicConfigurer::disable) : 기본 인증 방식 비활성화.
http.formLogin(AbstractHttpConfigurer::disable) : Form Login 비활성화. 사용자 정의 방식의 로그인을 사용 중이기 때문에 Form Login이 필요 없음.
http.sessionManagement(httpSecuritySessionManagementConfigurer -> ...) : 세션 생성 비활성화. JWT는 세션리스 인증방식이기 때문에 세션을 생성할 필요 없음.

http.authorizeHttpRequests(request -> request
				.requestMatchers(PERMITTED_URI).permitAll()
			    .requestMatchers("/api/admin/**").hasRole("ADMIN")
			    .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
			    .anyRequest().hasAnyRole(PERMITTED_ROLES)
			);

requestMatcher에 해당하는 주소로부터 오는 요청에 대한 설정을 한다.
PERMITTED_URI[] = {"/api/auth/**", "/signup"}에 해당하는 요청은 권한에 상관없이 전부 허용, /api/admin/**에 해당하는 요청은 ADMIN 권한을 가진 유저에게만 허용, 그 외 모든 요청은 PERMITTED_ROLES[] = {"USER", "ADMIN"}  USER 혹은 ADMIN 권한을 가진 유저에게만 허용한다는 뜻이다.

http.addFilterBefore(new JwtAuthenticationFilter(jwtService, memberService), UsernamePasswordAuthenticationFilter.class);

Spring Security에서는 요청이 들어올 때마다 필터체인에서 여러 필터를 순차적으로 처리하는데 각 필터는 요청을 가로채서 필요한 작업을 수행할 수 있다.
addFilterBefore 메서드를 사용하여 나중에 소개할 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter보다 앞에 배치해서 UsernamePasswordAuthenticationFilter보다 먼저 처리하도록 설정한다는 뜻이다.


2. Filter

com.security_board.security.jwt.filter.JwtAuthenticationFilter

package com.security_board.security.jwt.filter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
	public static final String PERMITTED_URI[] = { "/api/auth/login", "/signup" };
	private final JwtService jwtService;
	private final MemberService memberService;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		if (isPermittedURI(request.getRequestURI())) {
			SecurityContextHolder.getContext().setAuthentication(null);
			filterChain.doFilter(request, response);
			return;
		}

		String accessToken = jwtService.resolveTokenFromHeader(request);
		if (accessToken != null && jwtService.validateAccessToken(accessToken)) {
			setAuthenticationToContext(accessToken);
			filterChain.doFilter(request, response);
			return;
		}

		String refreshToken = jwtService.resolveTokenFromCookie(request, JwtRule.REFRESH_PREFIX);
		Member member = findMemberByRefreshToken(refreshToken);

		if (jwtService.validateRefreshToken(refreshToken, member.getEmail())) {
			String reissuedAccessToken = jwtService.generateAccessToken(response, member);
			jwtService.generateRefreshToken(response, member);

			setAuthenticationToContext(reissuedAccessToken);
			filterChain.doFilter(request, response);
			return;
		}
		jwtService.logout(member, response);
	}

	private boolean isPermittedURI(String requestURI) {
		return Arrays.stream(PERMITTED_URI).anyMatch(permitted -> {
			String regex = permitted.replace("**", ".*");
			return requestURI.matches(regex);
		});
	}

	private Member findMemberByRefreshToken(String refreshToken) {
		String email = jwtService.getEmailFromRefresh(refreshToken);
		return memberService.searchMember(email)
				.orElseThrow(() -> new RuntimeException("Member not found with email: " + email));
	}

	private void setAuthenticationToContext(String accessToken) {
		Authentication authentication = jwtService.getAuthentication(accessToken);
		SecurityContextHolder.getContext().setAuthentication(authentication);
	}
}

JWT 발급 이후, API를 요청할 때마다 처리하는 부분이 바로 JwtAuthenticationFilter다.
OncePerRequestFilter를 상속받아 doFilterInternal()을 구현한 후, SecurityConfig에서 addFilterBefore 메서드로 필터의 위치를 지정해주면 Filter chain에 포함되게 된다.

doFilterInternal()은 크게 3단계를 거치는데
1. API endpoint가 JWT 없이도 요청이 가능한경우

@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		if (isPermittedURI(request.getRequestURI())) {
			SecurityContextHolder.getContext().setAuthentication(null);
			filterChain.doFilter(request, response);
			return;
		}
        ...
 }
        private boolean isPermittedURI(String requestURI) {
		return Arrays.stream(PERMITTED_URI).anyMatch(permitted -> {
			String regex = permitted.replace("**", ".*");
			return requestURI.matches(regex);
		});
	}

isPermittedURI() 메서드를 통해 PERMITTED_URI[] = { "/api/auth/login", "/signup" }에 해당하는 주소의 요청이 들어올 경우 JWT와 관련된 별 다른 처리 없이 doFilter()를 호출한다.

2. 쿠키에 Access Token이 있는 경우

String accessToken = jwtService.resolveTokenFromHeader(request);
		if (accessToken != null && jwtService.validateAccessToken(accessToken)) {
			setAuthenticationToContext(accessToken);
			filterChain.doFilter(request, response);
			return;
		}

1) 쿠키에서 Access Token을 추출
2) Access Token의 시크릿 키, 구조가 유효하고 유효기간이 만료되지 않았는지 확인
3-1) Access Token에 문제가 없다면 Authentication 객체를 생성후 SecurityContextHolder에 저장하고 doFilter()를 호출
3-2) Access Token의 유효 기간이 만료됐다면 false를 반환하고 if문 패스
3-3) Access Token의 시크릿 키, 구조에 문제가 있다면 Exception을 던짐

3. Access Token이 만료된 경우

		...
        
		String refreshToken = jwtService.resolveTokenFromCookie(request, JwtRule.REFRESH_PREFIX);
		Member member = findMemberByRefreshToken(refreshToken);

		if (jwtService.validateRefreshToken(refreshToken, member.getEmail())) {
			String reissuedAccessToken = jwtService.generateAccessToken(response, member);
			jwtService.generateRefreshToken(response, member);

			setAuthenticationToContext(reissuedAccessToken);
			filterChain.doFilter(request, response);
			return;
		}
		jwtService.logout(member, response);
	}
    
    private Member findMemberByRefreshToken(String refreshToken) {
		String email = jwtService.getEmailFromRefresh(refreshToken);
		return memberService.searchMember(email)
				.orElseThrow(() -> new RuntimeException("Member not found with email: " + email));
	}

1) 쿠키에서 Refresh Token을 추출한다.
2) RTR 기법을 적용했기 때문에 Redis에 유효한 Refresh Token이 저장되어 있을 것이므로 Refresh Token을 통해 Redis에 쌍으로 저장된 email을 가져오고 그 email을 통해 Member객체를 불러옴
3) Refresh Token이 유효한지 확인(토큰의 시크릿 키, 구조, 유효 기간, Redis에 저장된 Refresh Token과 일치하는지 등)
4-1) Refresh token이 유효하다면 Access Token을 재발급하고 RTR 기법에 따라 Refresh Token 또한 다시 발급 + Redis에 저장
4-2) 조건에 부합하지 않으면 logout()호출 (Cookie 초기화 및 Redis에 저장된 정보 삭제)
5) 새로 발급한 Access Token으로 Authentication 객체를 생성하고 SecurityContextHolder에 저장
6) doFilter() 호출


3. DTO

com.security_board.security.jwt.dto.TokenRequest

package com.security_board.security.jwt.dto;


public record TokenRequest(String email, String password) {
}

4. Controller

package com.security_board.security.jwt.controller;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
	private final MemberService memberService;
	private final JwtService jwtService;

	@PostMapping("/login")
	public ResponseEntity<MemberDto> login(HttpServletResponse response, @RequestBody TokenRequest tokenRequest) {
		Optional<Member> requestUserOpt = memberService.searchMember(tokenRequest.email());

		if (requestUserOpt.isEmpty()) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
		}

		Member requestUser = requestUserOpt.get();

		if (!memberService.validatePassword(tokenRequest.password(), requestUser)) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
		}

		jwtService.generateAccessToken(response, requestUser);
		jwtService.generateRefreshToken(response, requestUser);

		MemberDto memberDto = MemberDto.getDto(requestUser.getEmail(), requestUser.getName());
		return ResponseEntity.ok(memberDto);
	}

	@GetMapping("/logout")
	public ResponseEntity<String> logout(HttpServletRequest request, HttpServletResponse response) {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
		Optional<Member> requestUserOpt = memberService.searchMember(userPrincipal.getMember().getEmail());
		jwtService.logout(requestUserOpt.get(), response);

		return ResponseEntity.ok("로그아웃되었습니다.");
	}
}
@PostMapping("/login")
	public ResponseEntity<MemberDto> login(HttpServletResponse response, @RequestBody TokenRequest tokenRequest) {
		Optional<Member> requestUserOpt = memberService.searchMember(tokenRequest.email());

		if (requestUserOpt.isEmpty()) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
		}

		Member requestUser = requestUserOpt.get();

		if (!memberService.validatePassword(tokenRequest.password(), requestUser)) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
		}

		jwtService.generateAccessToken(response, requestUser);
		jwtService.generateRefreshToken(response, requestUser);

		MemberDto memberDto = MemberDto.getDto(requestUser.getEmail(), requestUser.getName());
		return ResponseEntity.ok(memberDto);
	}

클라이언트 측에서 POST /api/login으로 요청하게 하며 RequestBody에 아이디와 비밀번호를 담아 JWT 발급을 요청한다.
JwtService의 토큰 발급 메서드들을 호출하여 토큰들을 발급한 후 쿠키에 담고 Response에는MemberDto 객체를 담아 전송한다.

	@GetMapping("/logout")
	public ResponseEntity<String> logout(HttpServletRequest request, HttpServletResponse response) {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
		Optional<Member> requestUserOpt = memberService.searchMember(userPrincipal.getMember().getEmail());
		jwtService.logout(requestUserOpt.get(), response);

		return ResponseEntity.ok("로그아웃되었습니다.");
	}

클라이언트 측에서 GET /api/logout으로 요청하게 하며 SecurityContextHolder에 저장된 Authentication 객체를 가져와 Member 객체를 불러오고 JwtService의 logout() 메서드를 통해 로그아웃 처리한다.