From e973b74c47eeb2bbd0c33ff34cf292f180099b7d Mon Sep 17 00:00:00 2001 From: corpi Date: Tue, 16 Dec 2025 10:49:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20jwt=20=ED=86=A0=ED=81=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/audio/common/TokenProvider.java | 108 ++++++++++++++++++ .../java/com/audio/config/SecurityConfig.java | 53 --------- .../filter/JwtAuthenticationFilter.java | 80 +++++++++++++ .../config/properties/TokenProperties.java | 22 ++++ .../com/audio/config/security/JwtConfig.java | 51 +++++++++ .../audio/config/security/SecurityConfig.java | 60 ++++++++++ .../{ => web}/GlobalApiResponseAdvice.java | 2 +- .../{ => web}/GlobalExceptionHandler.java | 4 +- .../auth/api/controller/AuthController.java | 2 - .../auth/application/RegisterUseCase.java | 9 +- src/main/resources/application.yaml | 3 - 11 files changed, 330 insertions(+), 64 deletions(-) create mode 100644 src/main/java/com/audio/common/TokenProvider.java delete mode 100644 src/main/java/com/audio/config/SecurityConfig.java create mode 100644 src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/audio/config/properties/TokenProperties.java create mode 100644 src/main/java/com/audio/config/security/JwtConfig.java create mode 100644 src/main/java/com/audio/config/security/SecurityConfig.java rename src/main/java/com/audio/config/{ => web}/GlobalApiResponseAdvice.java (97%) rename src/main/java/com/audio/config/{ => web}/GlobalExceptionHandler.java (95%) delete mode 100644 src/main/resources/application.yaml diff --git a/src/main/java/com/audio/common/TokenProvider.java b/src/main/java/com/audio/common/TokenProvider.java new file mode 100644 index 0000000..7d84fa0 --- /dev/null +++ b/src/main/java/com/audio/common/TokenProvider.java @@ -0,0 +1,108 @@ +package com.audio.common; + +import com.audio.config.properties.TokenProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class TokenProvider { + private final String issuer; + private final SecretKey key; + private final JwtParser parser; + private final Duration accessToken; + private final Duration refreshToken; + + /** Access Token 생성 */ + public String createAccessToken(Long accountId, List roles) { + Instant now = Instant.now(); + Instant exp = now.plus(accessToken); + + return Jwts.builder() + .setIssuer(issuer) + .setSubject(accountId.toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(exp)) + .claim("typ", "access") + .claim("roles", roles) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** Refresh Token 생성 */ + public String createRefreshToken(Long accountId) { + Instant now = Instant.now(); + Instant exp = now.plus(refreshToken); + + return Jwts.builder() + .setIssuer(issuer) + .setSubject(accountId.toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(exp)) + .claim("typ", "refresh") + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** 서명/만료/issuer 검증 */ + public boolean validate(String token) { + try { + parser.parseClaimsJws(stripBearer(token)); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** Access 토큰인지(typ=access)까지 확인 */ + public boolean validateAccessToken(String token) { + try { + Claims claims = getClaims(token); + return "access".equals(claims.get("typ", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** Refresh 토큰인지(typ=refresh)까지 확인 */ + public boolean validateRefreshToken(String token) { + try { + Claims claims = getClaims(token); + return "refresh".equals(claims.get("typ", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** Claims 추출(검증 포함) */ + public Claims getClaims(String token) { + return parser.parseClaimsJws(stripBearer(token)).getBody(); + } + + public String getSubject(String token) { + return getClaims(token).getSubject(); + } + + public Instant getExpiresAt(String token) { + return getClaims(token).getExpiration().toInstant(); + } + + public List getRoles(String token) { + return getClaims(token).get("roles", List.class); + } + + private String stripBearer(String token) { + if (token == null) return null; + return token.startsWith("Bearer ") ? token.substring(7) : token; + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/config/SecurityConfig.java b/src/main/java/com/audio/config/SecurityConfig.java deleted file mode 100644 index 81a94f8..0000000 --- a/src/main/java/com/audio/config/SecurityConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.audio.config; - -import jakarta.servlet.http.HttpServletResponse; -import java.util.ArrayList; -import java.util.List; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -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.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - - String[] allAuthorizedUrls = { - "/health-check", - "/api/v1/auth/**" - }; - - http.csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - // 허용 API - .requestMatchers(allAuthorizedUrls).permitAll() - // 그 외는 인증 필요 - .anyRequest() - .authenticated()) - - // 이후 ex 핸들러로 변경 - .exceptionHandling(ex -> ex.accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write("{\"message\":\"Forbidden\"}"); - })); - - return http.build(); - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } -} diff --git a/src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java b/src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..13c542f --- /dev/null +++ b/src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java @@ -0,0 +1,80 @@ +package com.audio.config.filter; + +import com.audio.common.TokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final TokenProvider tokenProvider; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final String[] permitAllPatterns; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + for (String pattern : permitAllPatterns) { + if (pathMatcher.match(pattern, path)) return true; + } + return false; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + String token = (authHeader != null && authHeader.startsWith("Bearer ")) + ? authHeader.substring(7) + : null; + + if (token == null || token.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + // 토큰이 있는데 유효하지 않으면 401로 즉시 종료 + if (!tokenProvider.validateAccessToken(token)) { + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"message\":\"Invalid or expired token\"}"); + return; + } + + // 유효하면 인증 객체 세팅 + String subject = tokenProvider.getSubject(token); // 예: userId/loginId + List roles = safeList(tokenProvider.getRoles(token)); + + var authorities = roles.stream() + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + var authentication = new UsernamePasswordAuthenticationToken(subject, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } + + private List safeList(List roles) { + return roles == null ? Collections.emptyList() : roles; + } +} diff --git a/src/main/java/com/audio/config/properties/TokenProperties.java b/src/main/java/com/audio/config/properties/TokenProperties.java new file mode 100644 index 0000000..248da9c --- /dev/null +++ b/src/main/java/com/audio/config/properties/TokenProperties.java @@ -0,0 +1,22 @@ +package com.audio.config.properties; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "token") +@Getter +@Setter +public class TokenProperties { + private String secret; + private String issuer; + private Lifetime lifetime; + + @Getter + @Setter + public static class Lifetime { + private Duration accessToken; + private Duration refreshToken; + } +} diff --git a/src/main/java/com/audio/config/security/JwtConfig.java b/src/main/java/com/audio/config/security/JwtConfig.java new file mode 100644 index 0000000..6eebce3 --- /dev/null +++ b/src/main/java/com/audio/config/security/JwtConfig.java @@ -0,0 +1,51 @@ +package com.audio.config.security; + +import com.audio.common.TokenProvider; +import com.audio.config.properties.TokenProperties; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import javax.crypto.SecretKey; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration +@EnableConfigurationProperties(TokenProperties.class) +public class JwtConfig { + + @Bean + public SecretKey jwtSecretKey(TokenProperties props) { + byte[] keyBytes = Decoders.BASE64.decode(props.getSecret()); + return Keys.hmacShaKeyFor(keyBytes); + } + + @Bean + public JwtParser jwtParser(TokenProperties props, SecretKey jwtSecretKey) { + String issuer = effectiveIssuer(props); + + return Jwts.parserBuilder() + .setSigningKey(jwtSecretKey) + .requireIssuer(issuer) + .build(); + } + + @Bean + public TokenProvider tokenProvider(TokenProperties props, SecretKey jwtSecretKey, JwtParser jwtParser) { + String issuer = effectiveIssuer(props); + + return new TokenProvider( + issuer, + jwtSecretKey, + jwtParser, + props.getLifetime().getAccessToken(), + props.getLifetime().getRefreshToken() + ); + } + + private String effectiveIssuer(TokenProperties props) { + return StringUtils.hasText(props.getIssuer()) ? props.getIssuer() : "audio"; + } +} diff --git a/src/main/java/com/audio/config/security/SecurityConfig.java b/src/main/java/com/audio/config/security/SecurityConfig.java new file mode 100644 index 0000000..2ed67b5 --- /dev/null +++ b/src/main/java/com/audio/config/security/SecurityConfig.java @@ -0,0 +1,60 @@ +package com.audio.config.security; + +import com.audio.common.TokenProvider; +import com.audio.config.filter.JwtAuthenticationFilter; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenProvider tokenProvider) throws Exception { + + String[] allAuthorizedUrls = { + "/health-check", + "/api/v1/auth/**" + }; + + JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, allAuthorizedUrls); + + http.csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(allAuthorizedUrls).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex + // 인증이 아예 없을 때(토큰 없음 등) -> 401 + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"message\":\"Unauthorized\"}"); + }) + // 인증은 됐는데 권한이 없을 때 -> 403 + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"message\":\"Forbidden\"}"); + }) + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/audio/config/GlobalApiResponseAdvice.java b/src/main/java/com/audio/config/web/GlobalApiResponseAdvice.java similarity index 97% rename from src/main/java/com/audio/config/GlobalApiResponseAdvice.java rename to src/main/java/com/audio/config/web/GlobalApiResponseAdvice.java index 25d4eb7..0f4fad2 100644 --- a/src/main/java/com/audio/config/GlobalApiResponseAdvice.java +++ b/src/main/java/com/audio/config/web/GlobalApiResponseAdvice.java @@ -1,4 +1,4 @@ -package com.audio.config; +package com.audio.config.web; import com.audio.common.ApiResponse; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/audio/config/GlobalExceptionHandler.java b/src/main/java/com/audio/config/web/GlobalExceptionHandler.java similarity index 95% rename from src/main/java/com/audio/config/GlobalExceptionHandler.java rename to src/main/java/com/audio/config/web/GlobalExceptionHandler.java index f7b8948..7ebb58a 100644 --- a/src/main/java/com/audio/config/GlobalExceptionHandler.java +++ b/src/main/java/com/audio/config/web/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package com.audio.config; +package com.audio.config.web; import com.audio.common.GlobalExceptionResponse; import jakarta.servlet.ServletException; @@ -9,14 +9,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; -import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.ErrorResponseException; -import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; diff --git a/src/main/java/com/audio/core/auth/api/controller/AuthController.java b/src/main/java/com/audio/core/auth/api/controller/AuthController.java index 18cb8b9..1d77261 100644 --- a/src/main/java/com/audio/core/auth/api/controller/AuthController.java +++ b/src/main/java/com/audio/core/auth/api/controller/AuthController.java @@ -5,7 +5,6 @@ import com.audio.core.auth.api.dto.res.SignUpResponse; import com.audio.core.auth.application.RegisterUseCase; import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; -import com.audio.core.auth.domain.service.AccountService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -18,7 +17,6 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class AuthController { - private final AccountService accountService; private final RegisterUseCase registerUseCase; @PostMapping("/register") diff --git a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java index 71f651f..bd18221 100644 --- a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java +++ b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java @@ -1,9 +1,11 @@ package com.audio.core.auth.application; +import com.audio.common.TokenProvider; import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.domain.service.AccountService; import com.audio.core.exception.auth.ExistLoginIdException; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -14,6 +16,7 @@ import org.springframework.stereotype.Component; public final class RegisterUseCase { private final AccountService accountService; + private final TokenProvider tokenProvider; public RegisterUseCaseResult execute(RegisterUseCaseCommand command) { String loginId = command.getLoginId(); @@ -25,10 +28,12 @@ public final class RegisterUseCase { } long accountId = accountService.register(loginId, command.getPassword()); + String accessToken = tokenProvider.createAccessToken(accountId, List.of("user")); + String refreshToken = tokenProvider.createRefreshToken(accountId); return RegisterUseCaseResult.builder() - .accessToken("accessToken") - .refreshToken("refreshToken") + .accessToken(accessToken) + .refreshToken(refreshToken) .build(); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index 3bc8ee6..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: audio_book