From ac753f2139cd9e1dee238623a048cc0fdd52e4c0 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:25:33 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20=EA=B8=B0=EB=B3=B8=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # build.gradle.kts # src/main/resources/application.yml --- .env.default | 1 + build.gradle.kts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.env.default b/.env.default index e69de29b..6a8bfc78 100644 --- a/.env.default +++ b/.env.default @@ -0,0 +1 @@ +JWT_SECRET=your-secret-key \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 83f4af62..c00f4b47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,11 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") implementation ("io.github.cdimascio:dotenv-java:3.0.0") + + // JWT + 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.withType { From ec8a010c15df018b1a46f2517d528d5f6f51efbd Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:26:03 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Feat:=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/back/global/exception/ErrorCode.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index f3b82695..118a26ca 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -10,7 +10,16 @@ public enum ErrorCode { // ======================== 공통 에러 ======================== BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다."); + NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다."), + + // ======================== 인증/인가 에러 ======================== + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_401", "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "유효하지 않은 토큰입니다."), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 액세스 토큰입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 리프레시 토큰입니다."), + REFRESH_TOKEN_REUSE(HttpStatus.FORBIDDEN, "AUTH_403", "재사용된 리프레시 토큰입니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_403", "권한이 없습니다."); + private final HttpStatus status; private final String code; From e713c938796130c4f097433e93a7eba9d4e4be43 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:39:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Feat:=20JWT=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/CustomUserDetails.java | 58 +++++++ .../security/JwtAuthenticationFilter.java | 54 +++++++ .../global/security/JwtTokenProvider.java | 142 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 src/main/java/com/back/global/security/CustomUserDetails.java create mode 100644 src/main/java/com/back/global/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/back/global/security/JwtTokenProvider.java diff --git a/src/main/java/com/back/global/security/CustomUserDetails.java b/src/main/java/com/back/global/security/CustomUserDetails.java new file mode 100644 index 00000000..eff19059 --- /dev/null +++ b/src/main/java/com/back/global/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.back.global.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +/** + * Spring Security에서 사용하는 사용자 인증 정보 클래스 + * - JWT에서 파싱한 사용자 정보를 담고 있음 + */ +@Getter +@AllArgsConstructor +public class CustomUserDetails implements UserDetails { + private Long userId; + private String username; + private String role; + + @Override + public Collection getAuthorities() { + // Spring Security 권한 체크는 "ROLE_" prefix 필요 + return List.of(new SimpleGrantedAuthority("ROLE_" + role)); + } + + @Override + public String getPassword() { + // JWT 인증에서는 비밀번호를 사용하지 않음 + return null; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/security/JwtAuthenticationFilter.java b/src/main/java/com/back/global/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..e8805599 --- /dev/null +++ b/src/main/java/com/back/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.back.global.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증을 처리하는 필터 + * - 모든 요청에 대해 JWT 토큰을 검사 + * - 토큰이 유효하면 Authentication 객체를 생성하여 SecurityContext에 저장 + */ +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain + ) throws ServletException, IOException { + + // Request Header에서 토큰 추출 + String token = resolveToken(request); + + // 토큰이 유효한 경우에만 Authentication 객체 생성 및 SecurityContext에 저장 + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // 다음 필터로 요청 전달 + filterChain.doFilter(request, response); + } + + /** + * Request의 Authorization 헤더에서 JWT 토큰을 추출 + * + * @param request HTTP 요청 객체 + * @return JWT 토큰 문자열 또는 null + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/back/global/security/JwtTokenProvider.java b/src/main/java/com/back/global/security/JwtTokenProvider.java new file mode 100644 index 00000000..3f34d4ef --- /dev/null +++ b/src/main/java/com/back/global/security/JwtTokenProvider.java @@ -0,0 +1,142 @@ +package com.back.global.security; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.List; + +/** + * JWT 생성 및 검증을 담당하는 Provider 클래스 + * - Access Token, Refresh Token 생성 + * - 토큰 검증 및 파싱 + * - Authentication 객체 생성 (Spring Security 연동) + */ +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpirationInSeconds; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpirationInSeconds; + + private SecretKey key; + + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + /** + * Access Token 생성 + * + * @param userId 사용자 PK + * @param username 로그인 ID + * @param role 권한 + * @return JWT Access Token 문자열 + */ + public String createAccessToken(Long userId, String username, String role) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenExpirationInSeconds * 1000); + + return Jwts.builder() + .subject(username) + .claim("userId", userId) + .claim("role", role) + .issuedAt(now) + .expiration(expiryDate) + .signWith(key) + .compact(); + } + + /** + * Refresh Token 생성 + * + * @param userId 사용자 PK + * @return JWT Refresh Token 문자열 + */ + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + refreshTokenExpirationInSeconds * 1000); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(expiryDate) + .signWith(key) + .compact(); + } + + /** + * JWT 토큰에서 인증 정보 추출 + * + * @param token JWT Access Token + * @return 인증 정보가 담긴 Authentication 객체 + */ + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + Long userId = claims.get("userId", Long.class); + String username = claims.getSubject(); + String role = claims.get("role", String.class); + + SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role); + CustomUserDetails principal = new CustomUserDetails(userId, username, role); + + return new UsernamePasswordAuthenticationToken(principal, token, List.of(authority)); + } + + /** + * JWT 토큰 검증 + * + * @param token JWT Access Token + * @return 유효한 토큰이면 true, 그렇지 않으면 false + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + /** + * JWT 파싱 + * + * @param token JWT 토큰 + * @return 토큰의 Claims + * @throws CustomException 토큰이 유효하지 않은 경우 + */ + private Claims parseClaims(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (JwtException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } +} From a9981a98c041b6a10dc041ddeb609ab68f2ed90f Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:47:36 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Feate:=20=EC=9D=B8=EC=A6=9D/=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=ED=8C=A8=20=EA=B3=B5=ED=86=B5=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/JwtAccessDeniedHandler.java | 37 ++++++++++++++++++ .../security/JwtAuthenticationEntryPoint.java | 38 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/main/java/com/back/global/security/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/back/global/security/JwtAuthenticationEntryPoint.java diff --git a/src/main/java/com/back/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/back/global/security/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..af93ccec --- /dev/null +++ b/src/main/java/com/back/global/security/JwtAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.back.global.security; + +import com.back.global.common.dto.RsData; +import com.back.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인가 실패(403 Forbidden) 처리 클래스 + * - 인증은 되었으나, 권한(Role)이 부족한 경우 + * - Json 형태로 에러 응답을 반환 + */ +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + RsData body = RsData.fail(ErrorCode.ACCESS_DENIED); + + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/src/main/java/com/back/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/back/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..c8f8fec0 --- /dev/null +++ b/src/main/java/com/back/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.back.global.security; + +import com.back.global.common.dto.RsData; +import com.back.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인증 실패(401 Unauthorized) 처리 클래스 + * - JwtAuthenticationFilter에서 토큰이 없거나 잘못된 경우 + * - 인증되지 않은 사용자가 보호된 API에 접근하려는 경우 + * - Json 형태로 에러 응답을 반환 + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + RsData body = RsData.fail(ErrorCode.UNAUTHORIZED); + + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} From 079ef09922fe8aec39441413003ad71e26215edf Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:34:29 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Feat:=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/JwtAuthenticationFilter.java | 2 ++ .../back/global/security/SecurityConfig.java | 28 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/back/global/security/JwtAuthenticationFilter.java b/src/main/java/com/back/global/security/JwtAuthenticationFilter.java index e8805599..88f192c8 100644 --- a/src/main/java/com/back/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/JwtAuthenticationFilter.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -16,6 +17,7 @@ * - 모든 요청에 대해 JWT 토큰을 검사 * - 토큰이 유효하면 Authentication 객체를 생성하여 SecurityContext에 저장 */ +@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 908e06be..5076e095 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -3,25 +3,45 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests( - auth -> auth - .requestMatchers("/**").permitAll() - .anyRequest().authenticated() + http + // 인가 규칙 설정 + .authorizeHttpRequests( + auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + + // 인증/인가 실패 핸들러 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler) // 403 ) + + // JWT 필터 추가 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + // 기타 설정 .headers( headers -> headers .frameOptions( From d279e2399b6c39cb91b318327cd6b79cff2d3fe7 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:06:51 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Test:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/JwtSecurityIntegrationTest.java | 70 +++++++++++++++++++ .../java/com/back/api/TestController.java | 29 ++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/test/java/com/back/JwtSecurityIntegrationTest.java create mode 100644 src/test/java/com/back/api/TestController.java diff --git a/src/test/java/com/back/JwtSecurityIntegrationTest.java b/src/test/java/com/back/JwtSecurityIntegrationTest.java new file mode 100644 index 00000000..5c233ca6 --- /dev/null +++ b/src/test/java/com/back/JwtSecurityIntegrationTest.java @@ -0,0 +1,70 @@ +package com.back; + +import com.back.global.security.JwtTokenProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class JwtSecurityIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + +// @Test +// @DisplayName("public 엔드포인트 접근 시 200 OK 반환") +// void givenNoToken_whenAccessPublic_thenReturn200() throws Exception { +// mockMvc.perform(get("/api/test/public")) +// .andExpect(status().isOk()); +// } + + @Test + @DisplayName("일반 유저가 /me 접근 시 200 OK 반환") + void givenUserToken_whenAccessMe_thenReturn200() throws Exception { + // ROLE_USER 토큰 발급 + String userToken = jwtTokenProvider.createAccessToken(3L, "user2", "USER"); + + mockMvc.perform(get("/api/test/me") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("토큰 없이 /me 접근 시 401 Unauthorized 반환") + void givenNoToken_whenAccessMe_thenReturn401() throws Exception { + mockMvc.perform(get("/api/test/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("관리자가 /admin 접근 시 200 OK 반환") + void givenAdminToken_whenAccessAdmin_thenReturn200() throws Exception { + // ROLE_ADMIN 토큰 발급 + String adminToken = jwtTokenProvider.createAccessToken(2L, "admin1", "ADMIN"); + + mockMvc.perform(get("/api/test/admin") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("일반 유저가 /admin 접근 시 403 Forbidden 반환") + void givenUserToken_whenAccessAdmin_thenReturn403() throws Exception { + // ROLE_USER 토큰 발급 + String userToken = jwtTokenProvider.createAccessToken(1L, "user1", "USER"); + + mockMvc.perform(get("/api/test/admin") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/back/api/TestController.java b/src/test/java/com/back/api/TestController.java new file mode 100644 index 00000000..edaa1ae7 --- /dev/null +++ b/src/test/java/com/back/api/TestController.java @@ -0,0 +1,29 @@ +package com.back.api; + +import com.back.global.security.CustomUserDetails; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/test") +public class TestController { + +// @GetMapping("/public") +// public String publicApi() { +// return "누구나 접근 가능"; +// } + + @GetMapping("/me") + public String me(@AuthenticationPrincipal CustomUserDetails user) { + return "내 정보: " + user.getUsername() + " (id=" + user.getUserId() + ")"; + } + + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public String adminOnly() { + return "관리자 전용 API"; + } +} From 199e749720516656231cacc5e0b35f451c12c3a9 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:23:35 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --amend --- src/main/resources/application-dev.yml | 7 ++++++- src/main/resources/application-test.yml | 7 ++++++- src/test/java/com/back/JwtSecurityIntegrationTest.java | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bb334003..43b932a8 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,4 +24,9 @@ springdoc: logging: level: - org.hibernate.orm.jdbc.bind: trace \ No newline at end of file + org.hibernate.orm.jdbc.bind: trace + +jwt: + secret: ${JWT_SECRET:test-jwt-secret-key-12345678901234567890} # 운영 시에는 반드시 환경 변수로 설정할 것 + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800} # 30분 (초 단위) + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800} # 7일 (초 단위) \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index f7468a79..6cc589f9 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -3,4 +3,9 @@ spring: url: jdbc:h2:mem:db_test;MODE=MySQL driver-class-name: org.h2.Driver username: sa - password: \ No newline at end of file + password: + +jwt: + secret: test-jwt-secret-key-12345678901234567890 + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800} # 30분 (초 단위) + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800} # 7일 (초 단위) \ No newline at end of file diff --git a/src/test/java/com/back/JwtSecurityIntegrationTest.java b/src/test/java/com/back/JwtSecurityIntegrationTest.java index 5c233ca6..10a6a717 100644 --- a/src/test/java/com/back/JwtSecurityIntegrationTest.java +++ b/src/test/java/com/back/JwtSecurityIntegrationTest.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -13,6 +14,7 @@ @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class JwtSecurityIntegrationTest { @Autowired