Skip to content

Commit e8a74ff

Browse files
authored
Merge pull request #37 from 33-Auto/SPM-505
[FEAT] jwt 인증 추가
2 parents 3691e86 + 9c8bddd commit e8a74ff

17 files changed

Lines changed: 488 additions & 6 deletions

build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ dependencies {
4141

4242
implementation 'org.springframework.kafka:spring-kafka'
4343
implementation 'com.fasterxml.jackson.core:jackson-databind'
44+
45+
// Jwt
46+
implementation "io.jsonwebtoken:jjwt-api:0.11.5"
47+
runtimeOnly "io.jsonwebtoken:jjwt-impl:0.11.5"
48+
runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.11.5"
49+
50+
implementation 'org.springframework.boot:spring-boot-starter-security'
4451
}
4552

4653
// Spring Cloud BOM 추가

src/main/java/com/sampoom/factory/api/material/entity/MaterialOrder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.sampoom.factory.api.material.entity;
22

3-
import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
3+
import com.sampoom.factory.common.entity.SoftDeleteEntity;
44
import com.sampoom.factory.common.exception.BadRequestException;
55
import com.sampoom.factory.common.response.ErrorStatus;
66
import jakarta.persistence.*;

src/main/java/com/sampoom/factory/api/mps/entity/Mps.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.sampoom.factory.api.mps.entity;
22

3-
import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
3+
import com.sampoom.factory.common.entity.SoftDeleteEntity;
44
import jakarta.persistence.*;
55
import lombok.AllArgsConstructor;
66
import lombok.Builder;

src/main/java/com/sampoom/factory/api/mps/entity/MpsPlan.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.sampoom.factory.api.mps.entity;
22

3-
import com.sampoom.factory.common.entitiy.SoftDeleteEntity;
3+
import com.sampoom.factory.common.entity.SoftDeleteEntity;
44
import jakarta.persistence.*;
55
import lombok.AllArgsConstructor;
66
import lombok.Builder;

src/main/java/com/sampoom/factory/api/part/entity/PartOrder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.sampoom.factory.api.part.entity;
22

3-
import com.sampoom.factory.common.entitiy.BaseTimeEntity;
3+
import com.sampoom.factory.common.entity.BaseTimeEntity;
44
import jakarta.persistence.*;
55
import lombok.*;
66

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.sampoom.factory.common.config.jwt;
2+
3+
import com.sampoom.factory.common.config.security.CustomAuthEntryPoint;
4+
import com.sampoom.factory.common.entity.Role;
5+
import com.sampoom.factory.common.entity.Workspace;
6+
import com.sampoom.factory.common.exception.CustomAuthenticationException;
7+
import com.sampoom.factory.common.response.ErrorStatus;
8+
import io.jsonwebtoken.Claims;
9+
import jakarta.servlet.FilterChain;
10+
import jakarta.servlet.ServletException;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
16+
import org.springframework.security.core.GrantedAuthority;
17+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
18+
import org.springframework.security.core.context.SecurityContextHolder;
19+
import org.springframework.stereotype.Component;
20+
import org.springframework.web.filter.OncePerRequestFilter;
21+
22+
import java.io.IOException;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
26+
@Slf4j
27+
@Component
28+
@RequiredArgsConstructor
29+
public class JwtAuthFilter extends OncePerRequestFilter {
30+
31+
private final JwtProvider jwtProvider;
32+
private final CustomAuthEntryPoint customAuthEntryPoint;
33+
34+
@Override
35+
protected void doFilterInternal(HttpServletRequest request,
36+
HttpServletResponse response,
37+
FilterChain filterChain)
38+
throws ServletException, IOException {
39+
String path = request.getRequestURI();
40+
if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.equals("/swagger-ui.html")) {
41+
filterChain.doFilter(request, response);
42+
return;
43+
}
44+
try {
45+
String accessToken = jwtProvider.resolveAccessToken(request);
46+
if (accessToken == null || accessToken.isBlank()) {
47+
filterChain.doFilter(request, response);
48+
return;
49+
}
50+
51+
Claims claims = jwtProvider.parse(accessToken);
52+
53+
// 토큰 타입 검증
54+
String type = claims.get("type", String.class);
55+
56+
// service 토큰 검증
57+
if ("service".equals(type)) {
58+
String role = claims.get("role", String.class);
59+
String subject = claims.getSubject(); // 토큰 발급자 정보 (auth-service)
60+
if (role == null) {
61+
throw new CustomAuthenticationException(ErrorStatus.NULL_TOKEN_ROLE);
62+
}
63+
if (role.isBlank()) {
64+
throw new CustomAuthenticationException(ErrorStatus.BLANK_TOKEN_ROLE);
65+
}
66+
if (!role.startsWith("SVC_")) {
67+
throw new CustomAuthenticationException(ErrorStatus.NOT_SERVICE_TOKEN);
68+
}
69+
70+
// Feign 내부 호출용 권한 통과
71+
UsernamePasswordAuthenticationToken auth =
72+
new UsernamePasswordAuthenticationToken(
73+
subject,
74+
null,
75+
List.of(new SimpleGrantedAuthority(role))
76+
);
77+
SecurityContextHolder.getContext().setAuthentication(auth);
78+
// service 토큰은 더 이상 검증할 필요 없음
79+
filterChain.doFilter(request, response);
80+
return;
81+
}
82+
83+
// 그 외 토큰 (refresh) 예외 처리
84+
if ("refresh".equals(type)) {
85+
SecurityContextHolder.clearContext(); // 인증 정보 제거
86+
throw new CustomAuthenticationException(ErrorStatus.NOT_ACCESS_TOKEN);
87+
}
88+
89+
// 토큰에서 userId, role 가져오기
90+
String userId = claims.getSubject();
91+
String roleStr = claims.get("role", String.class);
92+
String workspaceStr = claims.get("workspace", String.class);
93+
if (userId == null
94+
|| userId.isBlank()
95+
|| roleStr == null
96+
|| roleStr.isBlank()
97+
|| workspaceStr == null
98+
|| workspaceStr.isBlank()
99+
) {
100+
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
101+
}
102+
103+
Role role;
104+
Workspace workspace;
105+
try {
106+
role = Role.valueOf(roleStr);
107+
workspace = Workspace.valueOf(workspaceStr);
108+
} catch (IllegalArgumentException ex) {
109+
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
110+
}
111+
112+
// 권한 매핑 (Enum Role → Security 권한명)
113+
String roleAuthority = "ROLE_" + role.name();
114+
String workspaceAuthority = "ROLE_" + workspace.name();
115+
116+
// GrantedAuthority 리스트 생성
117+
List<GrantedAuthority> authorities = new ArrayList<>();
118+
authorities.add(new SimpleGrantedAuthority(roleAuthority));
119+
authorities.add(new SimpleGrantedAuthority(workspaceAuthority));
120+
121+
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
122+
userId, null, authorities
123+
);
124+
SecurityContextHolder.getContext().setAuthentication(authentication);
125+
filterChain.doFilter(request, response);
126+
} catch (CustomAuthenticationException ex) {
127+
SecurityContextHolder.clearContext();
128+
customAuthEntryPoint.commence(request, response, ex);
129+
} catch (Exception ex) {
130+
SecurityContextHolder.clearContext();
131+
customAuthEntryPoint.commence(request, response,
132+
new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN));
133+
}
134+
}
135+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.sampoom.factory.common.config.jwt;
2+
3+
import com.sampoom.factory.common.exception.BadRequestException;
4+
import com.sampoom.factory.common.exception.CustomAuthenticationException;
5+
import com.sampoom.factory.common.exception.UnauthorizedException;
6+
import com.sampoom.factory.common.response.ErrorStatus;
7+
import io.jsonwebtoken.*;
8+
import jakarta.servlet.http.Cookie;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.security.KeyFactory;
14+
import java.security.PublicKey;
15+
import java.security.interfaces.RSAPublicKey;
16+
import java.security.spec.X509EncodedKeySpec;
17+
import java.util.Base64;
18+
19+
@Component
20+
public class JwtProvider {
21+
// private final Key key;
22+
private final PublicKey publicKey;
23+
24+
public JwtProvider(@Value("${jwt.public-key-base64}") String publicKeyBase64) {
25+
if (publicKeyBase64 == null || publicKeyBase64.isBlank()) {
26+
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
27+
}
28+
try {
29+
this.publicKey = loadPublicKey(publicKeyBase64);
30+
} catch (BadRequestException e) {
31+
throw e;
32+
} catch (Exception e) {
33+
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
34+
}
35+
36+
}
37+
38+
private PublicKey loadPublicKey(String base64) throws Exception {
39+
try {
40+
byte[] keyBytes = Base64.getDecoder().decode(base64);
41+
PublicKey key = KeyFactory.getInstance("RSA")
42+
.generatePublic(new X509EncodedKeySpec(keyBytes));
43+
if (key instanceof RSAPublicKey rsaKey) {
44+
if (rsaKey.getModulus().bitLength() < 2048) {
45+
throw new BadRequestException(ErrorStatus.SHORT_PUBLIC_KEY);
46+
}
47+
}
48+
return key;
49+
} catch (BadRequestException e) {
50+
throw e;
51+
} catch (Exception e) {
52+
throw new BadRequestException(ErrorStatus.INVALID_PUBLIC_KEY);
53+
}
54+
}
55+
56+
public Claims parse(String token) {
57+
if (token == null || token.isBlank()) {
58+
throw new BadRequestException(ErrorStatus.NULL_BLANK_TOKEN);
59+
}
60+
try{
61+
return Jwts.parserBuilder().setSigningKey(publicKey).build()
62+
.parseClaimsJws(token).getBody();
63+
}
64+
catch (ExpiredJwtException e) {
65+
throw new CustomAuthenticationException(ErrorStatus.EXPIRED_TOKEN);
66+
}
67+
catch (Exception e) {
68+
// 잘못된 형식 or 위조된 토큰
69+
throw new CustomAuthenticationException(ErrorStatus.INVALID_TOKEN);
70+
}
71+
}
72+
73+
public String resolveAccessToken(HttpServletRequest request) {
74+
// 쿠키에서 ACCESS_TOKEN 찾기
75+
if (request.getCookies() != null) {
76+
for (Cookie cookie : request.getCookies()) {
77+
if ("ACCESS_TOKEN".equals(cookie.getName())) {
78+
return cookie.getValue();
79+
}
80+
}
81+
}
82+
// Bearer 방식일 때
83+
String header = request.getHeader("Authorization");
84+
if (header == null) return null;
85+
if (!header.startsWith("Bearer "))
86+
throw new UnauthorizedException(ErrorStatus.INVALID_TOKEN);
87+
return header.substring(7); // "Bearer " 제거
88+
}
89+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.sampoom.factory.common.config.security;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.sampoom.factory.common.response.ApiResponse;
5+
import com.sampoom.factory.common.response.ErrorStatus;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.security.access.AccessDeniedException;
11+
import org.springframework.security.web.access.AccessDeniedHandler;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.io.IOException;
15+
16+
@Slf4j
17+
@Component
18+
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
19+
20+
private final ObjectMapper objectMapper = new ObjectMapper();
21+
22+
@Override
23+
public void handle(HttpServletRequest request,
24+
HttpServletResponse response,
25+
AccessDeniedException accessDeniedException)
26+
throws IOException, ServletException {
27+
28+
ErrorStatus error = ErrorStatus.ACCESS_DENIED;
29+
30+
ApiResponse<Void> body = ApiResponse.errorWithCode(
31+
error.getCode(),
32+
error.getMessage()
33+
);
34+
35+
response.setStatus(error.getHttpStatus().value());
36+
response.setContentType("application/json;charset=UTF-8");
37+
response.getWriter().write(objectMapper.writeValueAsString(body));
38+
39+
log.warn("[Security] {} {} -> {}", request.getMethod(), request.getRequestURI(), error.getMessage());
40+
}
41+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.sampoom.factory.common.config.security;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.sampoom.factory.common.exception.CustomAuthenticationException;
5+
import com.sampoom.factory.common.response.ApiResponse;
6+
import com.sampoom.factory.common.response.ErrorStatus;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.security.core.AuthenticationException;
11+
import org.springframework.security.web.AuthenticationEntryPoint;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.io.IOException;
15+
16+
@Slf4j
17+
@Component
18+
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {
19+
20+
private final ObjectMapper objectMapper = new ObjectMapper();
21+
22+
@Override
23+
public void commence(HttpServletRequest request,
24+
HttpServletResponse response,
25+
AuthenticationException authException) throws IOException {
26+
27+
ErrorStatus error;
28+
if (authException.getCause() instanceof CustomAuthenticationException customEx) {
29+
error = customEx.getErrorStatus();
30+
} else if (authException instanceof CustomAuthenticationException customEx) {
31+
error = customEx.getErrorStatus();
32+
} else {
33+
error = ErrorStatus.INVALID_TOKEN;
34+
}
35+
36+
ApiResponse<Void> body = ApiResponse.errorWithCode(
37+
error.getCode(),
38+
error.getMessage()
39+
);
40+
41+
response.setStatus(error.getHttpStatus().value());
42+
response.setContentType("application/json;charset=UTF-8");
43+
response.getWriter().write(objectMapper.writeValueAsString(body));
44+
45+
log.warn("[Security] {} {} -> {}", request.getMethod(), request.getRequestURI(), error.getMessage());
46+
}
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.sampoom.factory.common.config.security;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
6+
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
7+
8+
@Configuration
9+
public class RoleHierarchyConfig {
10+
@Bean
11+
public RoleHierarchy roleHierarchy() {
12+
return RoleHierarchyImpl.fromHierarchy("""
13+
ROLE_ADMIN > ROLE_MD
14+
ROLE_ADMIN > ROLE_SALES
15+
ROLE_ADMIN > ROLE_INVENTORY
16+
ROLE_ADMIN > ROLE_PRODUCTION
17+
ROLE_ADMIN > ROLE_PURCHASE
18+
ROLE_ADMIN > ROLE_HR
19+
ROLE_ADMIN > ROLE_AGENCY
20+
ROLE_MD > ROLE_USER
21+
ROLE_SALES > ROLE_USER
22+
ROLE_INVENTORY > ROLE_USER
23+
ROLE_PRODUCTION > ROLE_USER
24+
ROLE_PURCHASE > ROLE_USER
25+
ROLE_HR > ROLE_USER
26+
ROLE_AGENCY > ROLE_USER
27+
""");
28+
}
29+
}

0 commit comments

Comments
 (0)