From 2dc664243e4f207459e57086cb0442ebd6f07693 Mon Sep 17 00:00:00 2001 From: TueBack Date: Sat, 20 Dec 2025 03:33:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Trip=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9D=B8=EA=B0=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?-=20Trip=20=EC=84=9C=EB=B9=84=EC=8A=A4:=20Public=20Key=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20JWT=20=EA=B2=80=EC=A6=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=8F=20UserContext=20Resolver=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Filter**: `JwtAuthenticationFilter`에서 Auth 서비스의 Public Key로 토큰 서명 검증 - **Resolver**: `@WithUserContext` 어노테이션을 통해 컨트롤러에 사용자 정보 주입 (`UserContextArgumentResolver`) --- build.gradle | 6 + .../filter/AuthenticationFilter.java | 82 +++++------ .../filter/JwtAuthenticationFilter.java | 136 ++++++++++++++++++ .../trip/infra/config/SecurityConfig.java | 33 +++++ src/main/resources/application.yml | 16 ++- 5 files changed, 231 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/JwtAuthenticationFilter.java diff --git a/build.gradle b/build.gradle index 1cf2573..34c1538 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,12 @@ dependencies { implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + implementation 'org.springframework.boot:spring-boot-starter-security' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' } tasks.named('test') { diff --git a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java index 778c411..f0a9b2e 100644 --- a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java +++ b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java @@ -1,41 +1,41 @@ -package com.retrip.trip.infra.adapter.in.presentation.filter; - -import com.retrip.trip.application.in.request.context.UserContext; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -public class AuthenticationFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String path = request.getRequestURI(); - String pathLowercase = path.toLowerCase(); - - // 제외할 URL 체크 - if (path.equals("/") || - pathLowercase.contains("swagger") || - pathLowercase.contains("api-docs") || - pathLowercase.contains("actuator") || - pathLowercase.contains("robots.txt") || - pathLowercase.contains("status-check")) { - filterChain.doFilter(request, response); - return; // 필터 종료 - } - - String token = request.getHeader("Authorization"); - if (token == null || token.isEmpty()) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } - request.setAttribute("userContext", UserContext.mockOf()); - filterChain.doFilter(request, response); - } -} +//package com.retrip.trip.infra.adapter.in.presentation.filter; +// +//import com.retrip.trip.application.in.request.context.UserContext; +//import jakarta.servlet.FilterChain; +//import jakarta.servlet.ServletException; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpServletResponse; +//import org.springframework.stereotype.Component; +//import org.springframework.web.filter.OncePerRequestFilter; +// +//import java.io.IOException; +// +//@Component +//public class AuthenticationFilter extends OncePerRequestFilter { +// +// @Override +// protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, +// FilterChain filterChain) throws ServletException, IOException { +// String path = request.getRequestURI(); +// String pathLowercase = path.toLowerCase(); +// +// // 제외할 URL 체크 +// if (path.equals("/") || +// pathLowercase.contains("swagger") || +// pathLowercase.contains("api-docs") || +// pathLowercase.contains("actuator") || +// pathLowercase.contains("robots.txt") || +// pathLowercase.contains("status-check")) { +// filterChain.doFilter(request, response); +// return; // 필터 종료 +// } +// +// String token = request.getHeader("Authorization"); +// if (token == null || token.isEmpty()) { +// response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// return; +// } +// request.setAttribute("userContext", UserContext.mockOf()); +// filterChain.doFilter(request, response); +// } +//} diff --git a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/JwtAuthenticationFilter.java b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6919595 --- /dev/null +++ b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/JwtAuthenticationFilter.java @@ -0,0 +1,136 @@ +package com.retrip.trip.infra.adapter.in.presentation.filter; + +import com.retrip.trip.application.in.request.context.UserContext; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Value("${jwt.public-key}") + private String publicKeyString; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + + if (isExcludedPath(path)) { + filterChain.doFilter(request, response); + return; + } + + String token = resolveToken(request); + + if (token != null && validateToken(token)) { + try { + + Claims claims = getClaims(token); + + + String subject = claims.getSubject(); + UUID memberId = UUID.fromString(subject); + + + String role = claims.get("auth", String.class); + + UserContext userContext = new UserContext( + memberId, + claims.get("nickname", String.class), + claims.get("email", String.class), + claims.get("name", String.class), + null, + 0 + ); + + request.setAttribute("userContext", userContext); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberId, + null, + role != null ? List.of(new SimpleGrantedAuthority(role)) : Collections.emptyList() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (Exception e) { + log.error("Token processing error: {}", e.getMessage()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean isExcludedPath(String path) { + String pathLowercase = path.toLowerCase(); + return path.equals("/") || + pathLowercase.contains("swagger") || + pathLowercase.contains("api-docs") || + pathLowercase.contains("actuator") || + pathLowercase.contains("robots.txt") || + pathLowercase.contains("status-check"); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private boolean validateToken(String token) { + try { + getClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + private Claims getClaims(String token) throws Exception { + String sanitizedKey = publicKeyString + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] publicBytes = Base64.getDecoder().decode(sanitizedKey); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PublicKey publicKey = keyFactory.generatePublic(keySpec); + + Jws jws = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token); + + return jws.getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/trip/infra/config/SecurityConfig.java b/src/main/java/com/retrip/trip/infra/config/SecurityConfig.java index 5fddc7b..38d0276 100644 --- a/src/main/java/com/retrip/trip/infra/config/SecurityConfig.java +++ b/src/main/java/com/retrip/trip/infra/config/SecurityConfig.java @@ -1,13 +1,46 @@ package com.retrip.trip.infra.config; +import com.retrip.trip.infra.adapter.in.presentation.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + + .requestMatchers( + "/", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**", "/status-check" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 607313c..c268d6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,7 +4,7 @@ spring: datasource: driver-class-name: org.h2.Driver - url: jdbc:h2:tcp://localhost/~/trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL + url: jdbc:h2:mem:trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL username: sa password: @@ -30,3 +30,17 @@ springdoc: enabled: true api-docs: enabled: true + +jwt: + public-key: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfO/CoX9EzhskpAKX9ADH0wfEQZP4rAq + ptqq80W2YaOHAnXu+oU1UrP0b9ccKKSzMCVDwdmXrecZB0dFLPnoazMEbVOf6MSwrhGfxupPRmmJ + sIYgmQwo8/vnjaq/GYFfnHyCy6yKL41G+GZVqgeKdhr+w1jUw4L9Fs0l2J/AYqwTxZOnzxrU5erP + GSE5Dd3AwWt/brxuwA7sRfVS3mbbsYyYExjUrEbst8VtF3Pis35T8YfSDKMOUgiDnp30EAdGU1Up + u59J3+ToLRrIqIszRZqmasrWTL2/ihPO76PSTIMAsJMScjAwjUXA47YOOy6Vkzy8r3bPTYHp5C1N + KucWwwIDAQAB + -----END PUBLIC KEY----- + +server: + port: 8081