From 3c4c5c7718728f4be9284f2a41b256e01c0c286e Mon Sep 17 00:00:00 2001 From: kim minji Date: Thu, 8 Jan 2026 23:02:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20security=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopitbe/config/SecurityConfig.java | 73 +++++++++++++++++++ .../loopitbe/jwt/JwtAuthenticationFilter.java | 58 +++++++++++++++ .../com/example/loopitbe/jwt/JwtProvider.java | 49 ++++++++++++- 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/loopitbe/config/SecurityConfig.java create mode 100644 src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java diff --git a/src/main/java/com/example/loopitbe/config/SecurityConfig.java b/src/main/java/com/example/loopitbe/config/SecurityConfig.java new file mode 100644 index 0000000..39a1892 --- /dev/null +++ b/src/main/java/com/example/loopitbe/config/SecurityConfig.java @@ -0,0 +1,73 @@ +package com.example.loopitbe.config; + +import com.example.loopitbe.jwt.JwtAuthenticationFilter; +import com.example.loopitbe.jwt.JwtProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtProvider jwtProvider; + + public SecurityConfig(JwtProvider jwtProvider) { + this.jwtProvider = jwtProvider; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + // 1. CSRF 비활성화 (JWT 사용) + .csrf(AbstractHttpConfigurer::disable) + + // 2. 세션 사용 안 함 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 3. Form Login, Basic Http 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // 인증 실패 시 401(Unauthorized) 반환 설정 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + + // 4. 인증/인가 규칙 + .authorizeHttpRequests(auth -> auth + // auth 관련 API는 허용 + .requestMatchers( + "/kakao-callback.html", // 로컬 테스트용 + "/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**" + ).permitAll() + + // OPTIONS 요청 허용 + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + + // 나머지는 인증 필요 + .anyRequest().authenticated() + ) + + // JWT 필터 등록 + .addFilterBefore( + new JwtAuthenticationFilter(jwtProvider), + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } +} diff --git a/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ddc8919 --- /dev/null +++ b/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package com.example.loopitbe.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + public JwtAuthenticationFilter(JwtProvider jwtProvider) { + this.jwtProvider = jwtProvider; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String accessToken = resolveToken(request); + + if (accessToken != null && jwtProvider.validateToken(accessToken)) { + Long userId = jwtProvider.getUserId(accessToken); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, // principal + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/java/com/example/loopitbe/jwt/JwtProvider.java b/src/main/java/com/example/loopitbe/jwt/JwtProvider.java index 78112d9..d18e2fa 100644 --- a/src/main/java/com/example/loopitbe/jwt/JwtProvider.java +++ b/src/main/java/com/example/loopitbe/jwt/JwtProvider.java @@ -1,24 +1,43 @@ package com.example.loopitbe.jwt; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.*; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Component; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; import java.util.Date; @Component public class JwtProvider { - private final String secretKey = System.getenv("JWT_TOKEN"); + private final String secretKeyString = System.getenv("JWT_TOKEN"); + private SecretKey secretKey; + private final long ACCESS_TOKEN_EXPIRE = 1000L * 60 * 30; // 30분 private final long REFRESH_TOKEN_EXPIRE = 1000L * 60 * 60 * 24 * 7; // 7일 + @PostConstruct + public void init() { + // 환경변수가 비어있을 경우를 대비한 방어 로직 (선택사항) + if (secretKeyString == null || secretKeyString.length() < 32) { + throw new RuntimeException("JWT_TOKEN 환경변수가 설정되지 않았거나 32자(256bit) 미만입니다."); + } + // String -> Key 객체 변환 (UTF-8 바이트 처리) + this.secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes(StandardCharsets.UTF_8)); + } + public String createAccessToken(Long userId) { return Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE)) - .signWith(SignatureAlgorithm.HS256, secretKey) + .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } @@ -27,7 +46,31 @@ public String createRefreshToken(Long userId) { .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE)) - .signWith(SignatureAlgorithm.HS256, secretKey) + .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } + + // security + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + System.out.println("JWT Validation Error: " + e.getMessage()); + return false; + } + } + + public Long getUserId(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return Long.valueOf(claims.getSubject()); + } } From 63744071f696c26bd0ff907989f6cfe59a7230d6 Mon Sep 17 00:00:00 2001 From: kim minji Date: Sun, 11 Jan 2026 16:42:49 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81:=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=BB=A4=EC=8A=A4=ED=85=80=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/loopitbe/exception/ErrorCode.java | 6 ++++- .../loopitbe/jwt/JwtAuthenticationFilter.java | 24 ++++++++++++------- .../com/example/loopitbe/jwt/JwtProvider.java | 16 +++++++++---- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/example/loopitbe/exception/ErrorCode.java b/src/main/java/com/example/loopitbe/exception/ErrorCode.java index 68c54db..6a28ab1 100644 --- a/src/main/java/com/example/loopitbe/exception/ErrorCode.java +++ b/src/main/java/com/example/loopitbe/exception/ErrorCode.java @@ -7,8 +7,12 @@ public enum ErrorCode { DUPLICATED_NICKNAME(HttpStatus.CONFLICT, "중복된 닉네임 입니다."), KAKAO_AUTHENTICATED_FAILED(HttpStatus.BAD_REQUEST, "카카오 인증에 실패하였습니다."), JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "OAuth 인증 중 Json 파싱에 실패하였습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 리프레시 토큰입니다."); + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 액세스 토큰입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 리프레시 토큰입니다."), + UNSUPPORTED_JWT(HttpStatus.UNAUTHORIZED, "지원되지 않는 JWT 토큰입니다."), + EMPTY_JWT(HttpStatus.UNAUTHORIZED, "JWT 토큰이 비어있거나 잘못되었습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java index ddc8919..144624c 100644 --- a/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/loopitbe/jwt/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.example.loopitbe.jwt; +import com.example.loopitbe.exception.CustomException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -30,17 +31,22 @@ protected void doFilterInternal( String accessToken = resolveToken(request); - if (accessToken != null && jwtProvider.validateToken(accessToken)) { - Long userId = jwtProvider.getUserId(accessToken); + try { + if (accessToken != null && jwtProvider.validateToken(accessToken)) { + Long userId = jwtProvider.getUserId(accessToken); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userId, // principal - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, // principal + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); - SecurityContextHolder.getContext().setAuthentication(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (CustomException e) { + SecurityContextHolder.clearContext(); // 401 에러 발생 유도 + request.setAttribute("exception", e.getErrorCode()); // 에러코드 전달 } filterChain.doFilter(request, response); diff --git a/src/main/java/com/example/loopitbe/jwt/JwtProvider.java b/src/main/java/com/example/loopitbe/jwt/JwtProvider.java index d18e2fa..ffb9cf5 100644 --- a/src/main/java/com/example/loopitbe/jwt/JwtProvider.java +++ b/src/main/java/com/example/loopitbe/jwt/JwtProvider.java @@ -1,5 +1,7 @@ package com.example.loopitbe.jwt; +import com.example.loopitbe.exception.CustomException; +import com.example.loopitbe.exception.ErrorCode; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.*; @@ -24,11 +26,10 @@ public class JwtProvider { @PostConstruct public void init() { - // 환경변수가 비어있을 경우를 대비한 방어 로직 (선택사항) if (secretKeyString == null || secretKeyString.length() < 32) { throw new RuntimeException("JWT_TOKEN 환경변수가 설정되지 않았거나 32자(256bit) 미만입니다."); } - // String -> Key 객체 변환 (UTF-8 바이트 처리) + // String -> Key 객체 변환 this.secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes(StandardCharsets.UTF_8)); } @@ -58,9 +59,14 @@ public boolean validateToken(String token) { .build() .parseClaimsJws(token); return true; - } catch (JwtException | IllegalArgumentException e) { - System.out.println("JWT Validation Error: " + e.getMessage()); - return false; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.EXPIRED_ACCESS_TOKEN); + } catch (UnsupportedJwtException e) { + throw new CustomException(ErrorCode.UNSUPPORTED_JWT); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.EMPTY_JWT); } }