feat: jwt 토큰 생성

This commit is contained in:
2025-12-16 10:49:14 +09:00
parent d43b8b8f9b
commit e973b74c47
11 changed files with 330 additions and 64 deletions

View File

@@ -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<String> 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<String> 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;
}
}

View File

@@ -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();
}
}

View File

@@ -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<String> 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<String> safeList(List<String> roles) {
return roles == null ? Collections.emptyList() : roles;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}
}

View File

@@ -1,4 +1,4 @@
package com.audio.config; package com.audio.config.web;
import com.audio.common.ApiResponse; import com.audio.common.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;

View File

@@ -1,4 +1,4 @@
package com.audio.config; package com.audio.config.web;
import com.audio.common.GlobalExceptionResponse; import com.audio.common.GlobalExceptionResponse;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -9,14 +9,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.web.ErrorResponseException; import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest; import org.springframework.web.context.request.WebRequest;

View File

@@ -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.RegisterUseCase;
import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand; import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import com.audio.core.auth.domain.service.AccountService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -18,7 +17,6 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthController { public class AuthController {
private final AccountService accountService;
private final RegisterUseCase registerUseCase; private final RegisterUseCase registerUseCase;
@PostMapping("/register") @PostMapping("/register")

View File

@@ -1,9 +1,11 @@
package com.audio.core.auth.application; 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.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import com.audio.core.auth.domain.service.AccountService; import com.audio.core.auth.domain.service.AccountService;
import com.audio.core.exception.auth.ExistLoginIdException; import com.audio.core.exception.auth.ExistLoginIdException;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -14,6 +16,7 @@ import org.springframework.stereotype.Component;
public final class RegisterUseCase { public final class RegisterUseCase {
private final AccountService accountService; private final AccountService accountService;
private final TokenProvider tokenProvider;
public RegisterUseCaseResult execute(RegisterUseCaseCommand command) { public RegisterUseCaseResult execute(RegisterUseCaseCommand command) {
String loginId = command.getLoginId(); String loginId = command.getLoginId();
@@ -25,10 +28,12 @@ public final class RegisterUseCase {
} }
long accountId = accountService.register(loginId, command.getPassword()); long accountId = accountService.register(loginId, command.getPassword());
String accessToken = tokenProvider.createAccessToken(accountId, List.of("user"));
String refreshToken = tokenProvider.createRefreshToken(accountId);
return RegisterUseCaseResult.builder() return RegisterUseCaseResult.builder()
.accessToken("accessToken") .accessToken(accessToken)
.refreshToken("refreshToken") .refreshToken(refreshToken)
.build(); .build();
} }

View File

@@ -1,3 +0,0 @@
spring:
application:
name: audio_book