feat: jwt 토큰 생성
This commit is contained in:
108
src/main/java/com/audio/common/TokenProvider.java
Normal file
108
src/main/java/com/audio/common/TokenProvider.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/audio/config/security/JwtConfig.java
Normal file
51
src/main/java/com/audio/config/security/JwtConfig.java
Normal 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";
|
||||
}
|
||||
}
|
||||
60
src/main/java/com/audio/config/security/SecurityConfig.java
Normal file
60
src/main/java/com/audio/config/security/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.audio.config;
|
||||
package com.audio.config.web;
|
||||
|
||||
import com.audio.common.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: audio_book
|
||||
Reference in New Issue
Block a user