Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ dependencies {

implementation 'org.springframework.kafka:spring-kafka'
implementation 'com.fasterxml.jackson.core:jackson-databind'

// Jwt
implementation "io.jsonwebtoken:jjwt-api:0.11.5"
runtimeOnly "io.jsonwebtoken:jjwt-impl:0.11.5"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.11.5"

implementation 'org.springframework.boot:spring-boot-starter-security'
}

// Spring Cloud BOM 추가
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.sampoom.factory.api.material.entity;

import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
import com.sampoom.factory.common.entity.SoftDeleteEntity;
import com.sampoom.factory.common.exception.BadRequestException;
import com.sampoom.factory.common.response.ErrorStatus;
import jakarta.persistence.*;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/sampoom/factory/api/mps/entity/Mps.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.sampoom.factory.api.mps.entity;

import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
import com.sampoom.factory.common.entity.SoftDeleteEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.sampoom.factory.api.mps.entity;

import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
import com.sampoom.factory.common.entity.SoftDeleteEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.sampoom.factory.api.part.entity;

import com.sampoom.factory.common.entitiy.BaseTimeEntity;
import com.sampoom.factory.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;

Expand Down
135 changes: 135 additions & 0 deletions src/main/java/com/sampoom/factory/common/config/jwt/JwtAuthFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.sampoom.factory.common.config.jwt;

import com.sampoom.factory.common.config.security.CustomAuthEntryPoint;
import com.sampoom.factory.common.entity.Role;
import com.sampoom.factory.common.entity.Workspace;
import com.sampoom.factory.common.exception.CustomAuthenticationException;
import com.sampoom.factory.common.response.ErrorStatus;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final CustomAuthEntryPoint customAuthEntryPoint;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.equals("/swagger-ui.html")) {
filterChain.doFilter(request, response);
return;
}
try {
String accessToken = jwtProvider.resolveAccessToken(request);
if (accessToken == null || accessToken.isBlank()) {
filterChain.doFilter(request, response);
return;
}

Claims claims = jwtProvider.parse(accessToken);

// 토큰 타입 검증
String type = claims.get("type", String.class);

// service 토큰 검증
if ("service".equals(type)) {
String role = claims.get("role", String.class);
String subject = claims.getSubject(); // 토큰 발급자 정보 (auth-service)
if (role == null) {
throw new CustomAuthenticationException(ErrorStatus.NULL_TOKEN_ROLE);
}
if (role.isBlank()) {
throw new CustomAuthenticationException(ErrorStatus.BLANK_TOKEN_ROLE);
}
if (!role.startsWith("SVC_")) {
throw new CustomAuthenticationException(ErrorStatus.NOT_SERVICE_TOKEN);
}

// Feign 내부 호출용 권한 통과
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
subject,
null,
List.of(new SimpleGrantedAuthority(role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
// service 토큰은 더 이상 검증할 필요 없음
filterChain.doFilter(request, response);
return;
}

// 그 외 토큰 (refresh) 예외 처리
if ("refresh".equals(type)) {
SecurityContextHolder.clearContext(); // 인증 정보 제거
throw new CustomAuthenticationException(ErrorStatus.NOT_ACCESS_TOKEN);
}

// 토큰에서 userId, role 가져오기
String userId = claims.getSubject();
String roleStr = claims.get("role", String.class);
String workspaceStr = claims.get("workspace", String.class);
if (userId == null
|| userId.isBlank()
|| roleStr == null
|| roleStr.isBlank()
|| workspaceStr == null
|| workspaceStr.isBlank()
) {
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
}

Role role;
Workspace workspace;
try {
role = Role.valueOf(roleStr);
workspace = Workspace.valueOf(workspaceStr);
} catch (IllegalArgumentException ex) {
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
}

// 권한 매핑 (Enum Role → Security 권한명)
String roleAuthority = "ROLE_" + role.name();
String workspaceAuthority = "ROLE_" + workspace.name();

// GrantedAuthority 리스트 생성
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(roleAuthority));
authorities.add(new SimpleGrantedAuthority(workspaceAuthority));

UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userId, null, authorities
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (CustomAuthenticationException ex) {
SecurityContextHolder.clearContext();
customAuthEntryPoint.commence(request, response, ex);
} catch (Exception ex) {
SecurityContextHolder.clearContext();
customAuthEntryPoint.commence(request, response,
new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.sampoom.factory.common.config.jwt;

import com.sampoom.factory.common.exception.BadRequestException;
import com.sampoom.factory.common.exception.CustomAuthenticationException;
import com.sampoom.factory.common.exception.UnauthorizedException;
import com.sampoom.factory.common.response.ErrorStatus;
import io.jsonwebtoken.*;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

@Component
public class JwtProvider {
// private final Key key;
private final PublicKey publicKey;

public JwtProvider(@Value("${jwt.public-key-base64}") String publicKeyBase64) {
if (publicKeyBase64 == null || publicKeyBase64.isBlank()) {
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
}
try {
this.publicKey = loadPublicKey(publicKeyBase64);
} catch (BadRequestException e) {
throw e;
} catch (Exception e) {
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
}

}

private PublicKey loadPublicKey(String base64) throws Exception {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64);
PublicKey key = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(keyBytes));
if (key instanceof RSAPublicKey rsaKey) {
if (rsaKey.getModulus().bitLength() < 2048) {
throw new BadRequestException(ErrorStatus.SHORT_PUBLIC_KEY);
}
}
return key;
} catch (BadRequestException e) {
throw e;
} catch (Exception e) {
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
}
}

public Claims parse(String token) {
if (token == null || token.isBlank()) {
throw new BadRequestException(ErrorStatus.NULL_BLANK_TOKEN);
}
try{
return Jwts.parserBuilder().setSigningKey(publicKey).build()
.parseClaimsJws(token).getBody();
}
catch (ExpiredJwtException e) {
throw new CustomAuthenticationException(ErrorStatus.EXPIRED_TOKEN);
}
catch (Exception e) {
// 잘못된 형식 or 위조된 토큰
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
}
}

public String resolveAccessToken(HttpServletRequest request) {
// 쿠키에서 ACCESS_TOKEN 찾기
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("ACCESS_TOKEN".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
// Bearer 방식일 때
String header = request.getHeader("Authorization");
if (header == null) return null;
if (!header.startsWith("Bearer "))
throw new UnauthorizedException(ErrorStatus.INVALID_TOKEN);
return header.substring(7); // "Bearer " 제거
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.sampoom.factory.common.config.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sampoom.factory.common.response.ApiResponse;
import com.sampoom.factory.common.response.ErrorStatus;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {

ErrorStatus error = ErrorStatus.ACCESS_DENIED;

ApiResponse<Void> body = ApiResponse.errorWithCode(
error.getCode(),
error.getMessage()
);

response.setStatus(error.getHttpStatus().value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));

log.warn("[Security] {} {} -> {}", request.getMethod(), request.getRequestURI(), error.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.sampoom.factory.common.config.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sampoom.factory.common.exception.CustomAuthenticationException;
import com.sampoom.factory.common.response.ApiResponse;
import com.sampoom.factory.common.response.ErrorStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Slf4j
@Component
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {

ErrorStatus error;
if (authException.getCause() instanceof CustomAuthenticationException customEx) {
error = customEx.getErrorStatus();
} else if (authException instanceof CustomAuthenticationException customEx) {
error = customEx.getErrorStatus();
} else {
error = ErrorStatus.INVALID_TOKEN;
}

ApiResponse<Void> body = ApiResponse.errorWithCode(
error.getCode(),
error.getMessage()
);

response.setStatus(error.getHttpStatus().value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));

log.warn("[Security] {} {} -> {}", request.getMethod(), request.getRequestURI(), error.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sampoom.factory.common.config.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;

@Configuration
public class RoleHierarchyConfig {
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_MD
ROLE_ADMIN > ROLE_SALES
ROLE_ADMIN > ROLE_INVENTORY
ROLE_ADMIN > ROLE_PRODUCTION
ROLE_ADMIN > ROLE_PURCHASE
ROLE_ADMIN > ROLE_HR
ROLE_ADMIN > ROLE_AGENCY
ROLE_MD > ROLE_USER
ROLE_SALES > ROLE_USER
ROLE_INVENTORY > ROLE_USER
ROLE_PRODUCTION > ROLE_USER
ROLE_PURCHASE > ROLE_USER
ROLE_HR > ROLE_USER
ROLE_AGENCY > ROLE_USER
""");
}
}
Loading