Skip to content

Commit cc31044

Browse files
authored
Merge pull request #88 from SynergyX-AI-Pattern/feat/#85_login
[FEAT] 회원가입, 로그인, 로그아웃 구현 #85
2 parents 9d18841 + 33ba1cd commit cc31044

28 files changed

+875
-52
lines changed

src/main/java/com/synergyx/trading/apiPayload/code/status/ErrorStatus.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ public enum ErrorStatus implements BaseErrorCode {
6363
IMAGE_FILE_MISSING(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드된 이미지가 없습니다."),
6464
IMAGE_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "IMAGE4002", "업로드 가능한 최대 용량은 5MB입니다."),
6565
INVALID_IMAGE_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4003", "지원하지 않는 이미지 형식입니다."),
66+
67+
// 인증 관련 예외 처리
68+
AUTH_DUPLICATE_EMAIL(HttpStatus.CONFLICT, "AUTH4001", "이미 사용 중인 이메일입니다."),
69+
AUTH_INVALID_EMAIL_FORMAT(HttpStatus.BAD_REQUEST, "AUTH4002", "올바른 이메일 형식이 아닙니다."),
70+
AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH4003", "인증 정보가 유효하지 않습니다."),
71+
AUTH_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH4004", "이메일 또는 비밀번호가 잘못되었습니다."),
6672
;
6773

6874
private final HttpStatus httpStatus;

src/main/java/com/synergyx/trading/apiPayload/code/status/SuccessStatus.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ public enum SuccessStatus implements BaseCode {
4848
// 감정 투자 일기
4949
SUCCESS_WRITE_EMOTION_DIARY(HttpStatus.OK, "DIARY200", "감정 투자 일기 작성 성공"),
5050
SUCCESS_DELETE_EMOTION_DIARY(HttpStatus.OK, "DIARY2002", "감정 투자 일기가 삭제되었습니다."),
51+
52+
// 인증
53+
AUTH_LOGIN_SUCCESS(HttpStatus.OK, "AUTH200", "로그인 성공"),
54+
AUTH_SIGNUP_SUCCESS(HttpStatus.OK, "AUTH201", "회원가입 성공"),
55+
AUTH_LOGOUT_SUCCESS(HttpStatus.OK, "AUTH202", "로그아웃 성공")
5156
;
5257

5358
private final HttpStatus httpStatus;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.synergyx.trading.config.context;
2+
3+
import com.synergyx.trading.apiPayload.code.status.ErrorStatus;
4+
import com.synergyx.trading.apiPayload.exception.GeneralException;
5+
import com.synergyx.trading.config.security.UserAuthentication;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.context.SecurityContextHolder;
10+
import org.springframework.stereotype.Service;
11+
12+
@Service
13+
@RequiredArgsConstructor
14+
@Slf4j
15+
public class SecurityUserContext implements UserContext {
16+
17+
/**
18+
* 현재 인증된 User의 Id를 가져옵니다.
19+
*/
20+
@Override
21+
public Long getCurrentUserId() {
22+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
23+
24+
if (authentication == null) {
25+
log.warn("[SecurityUserContext] 인증 객체가 null입니다.");
26+
throw new GeneralException(ErrorStatus._UNAUTHORIZED);
27+
}
28+
29+
if (!(authentication instanceof UserAuthentication)) {
30+
log.warn("[SecurityUserContext] 인증 객체 타입이 예상과 다릅니다. 현재 타입: {}",
31+
authentication.getClass().getName());
32+
log.debug("[SecurityUserContext] authentication 전체 정보: {}", authentication);
33+
throw new GeneralException(ErrorStatus._UNAUTHORIZED);
34+
}
35+
36+
UserAuthentication userAuthentication = (UserAuthentication) authentication;
37+
Long userId = userAuthentication.getUserId();
38+
39+
if (userId == null) {
40+
log.warn("[SecurityUserContext] 인증된 사용자 ID가 null입니다.");
41+
throw new GeneralException(ErrorStatus._UNAUTHORIZED);
42+
}
43+
44+
return userId;
45+
}
46+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.synergyx.trading.config.context;
2+
3+
public interface UserContext {
4+
Long getCurrentUserId();
5+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.synergyx.trading.config.security;
2+
3+
import jakarta.servlet.ServletException;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import org.springframework.security.access.AccessDeniedException;
7+
import org.springframework.security.web.access.AccessDeniedHandler;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.io.IOException;
11+
12+
@Component
13+
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
14+
@Override
15+
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
16+
setResponse(response);
17+
}
18+
19+
private void setResponse(HttpServletResponse response) {
20+
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
21+
}
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.synergyx.trading.config.security;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import org.springframework.security.core.AuthenticationException;
6+
import org.springframework.security.web.AuthenticationEntryPoint;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
11+
12+
@Override
13+
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
14+
setResponse(response);
15+
}
16+
17+
private void setResponse(HttpServletResponse response) {
18+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
19+
}
20+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package com.synergyx.trading.config.security;
2+
3+
import com.synergyx.trading.apiPayload.code.status.ErrorStatus;
4+
import com.synergyx.trading.apiPayload.exception.GeneralException;
5+
import com.synergyx.trading.config.token.JwtTokenProvider;
6+
import com.synergyx.trading.model.User;
7+
import com.synergyx.trading.repository.UserRepository;
8+
import com.synergyx.trading.util.ErrorResponseUtil;
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.NonNull;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.security.core.context.SecurityContextHolder;
17+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
18+
import org.springframework.stereotype.Component;
19+
import org.springframework.util.AntPathMatcher;
20+
import org.springframework.web.filter.OncePerRequestFilter;
21+
22+
import java.io.IOException;
23+
import java.util.Collection;
24+
import java.util.List;
25+
import java.util.Collections;
26+
27+
@Slf4j
28+
@Component
29+
@RequiredArgsConstructor
30+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
31+
32+
private final JwtTokenProvider jwtTokenProvider;
33+
private final UserRepository userRepository;
34+
private final AntPathMatcher pathMatcher = new AntPathMatcher();
35+
36+
// no auth 허용 url
37+
private static final List<String> NO_AUTH_URLS = List.of(
38+
"/auth/login/**",
39+
"/auth/signup/**",
40+
"/swagger-ui/**",
41+
"/v3/api-docs/**",
42+
"/test/**"
43+
);
44+
45+
/**
46+
* JWT 인증 필터 처리 메서드
47+
* <p>
48+
* Access Token을 추출하고, 토큰 유효성을 검증합니다.
49+
* SecurityContext에 인증 정보를 등록합니다.
50+
* 유효하지 않은 토큰의 경우 {@link ErrorResponseUtil} 을 통해 인증 실패 응답을 반환합니다.
51+
*
52+
* @param request
53+
* @param response
54+
* @param filterChain
55+
* @throws ServletException
56+
* @throws IOException
57+
*/
58+
@Override
59+
protected void doFilterInternal(@NonNull HttpServletRequest request,
60+
@NonNull HttpServletResponse response,
61+
@NonNull FilterChain filterChain) throws ServletException, IOException {
62+
63+
String requestURI = request.getRequestURI();
64+
log.info("Request URI: {}", request.getRequestURI());
65+
66+
// 인증이 필요 없는 URI 필터 통과
67+
if (isExcludedPath(requestURI)) {
68+
log.debug("[JWT] 필터 제외 대상: {}", requestURI);
69+
filterChain.doFilter(request, response);
70+
return;
71+
}
72+
73+
String header = request.getHeader("Authorization");
74+
// 401 error
75+
if (header == null || !header.startsWith("Bearer ")) {
76+
log.warn("[JWT] Authorization 헤더 없음 또는 형식 오류 - IP: {}", request.getRemoteAddr());
77+
sendUnauthorized(response);
78+
return;
79+
}
80+
81+
// JWT 추출
82+
String token = header.substring(7);
83+
if (token.isBlank()) {
84+
log.warn("[JWT] 토큰이 비어 있음");
85+
sendUnauthorized(response);
86+
return;
87+
}
88+
89+
try {
90+
// log.debug("Extracted JWT: {}", token);
91+
92+
// validate
93+
JwtValidationType result = jwtTokenProvider.validateToken(token);
94+
95+
if (result != JwtValidationType.VALID_JWT) {
96+
// Invalid token 관련 디테일 로그
97+
log.warn("[JWT] 인증 실패 - validationType: {}, user-agent: {}", result.name(), request.getHeader("User-Agent"));
98+
throw new GeneralException(ErrorStatus.AUTH_INVALID_TOKEN);
99+
}
100+
101+
Long userId = jwtTokenProvider.parseUserId(token);
102+
103+
// DB에서 AccessToken 일치여부 확인
104+
User user = userRepository.findById(userId)
105+
.orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_INVALID_TOKEN));
106+
107+
if (user.getAccessToken() == null || !user.getAccessToken().equals(token)) {
108+
log.warn("[JWT] DB에 저장된 토큰과 불일치 - userId={}", userId);
109+
throw new GeneralException(ErrorStatus.AUTH_INVALID_TOKEN);
110+
}
111+
112+
// authentication 생성 -> principal에 userId 저장
113+
UserAuthentication authentication = new UserAuthentication(userId, null, Collections.emptyList());
114+
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
115+
SecurityContextHolder.getContext().setAuthentication(authentication);
116+
117+
filterChain.doFilter(request, response);
118+
119+
} catch (GeneralException exception) {
120+
// Invalid token 관련 응답 통일
121+
sendUnauthorized(response);
122+
} catch (Exception exception) {
123+
log.error("JWT 처리 중 알 수 없는 에러: {}", exception.getMessage(), exception);
124+
// Invalid token 관련 응답 통일
125+
sendUnauthorized(response);
126+
}
127+
}
128+
129+
/**
130+
* URI가 인증 제외 경로인지 확인합니다.
131+
*
132+
* @param requestURI
133+
* @return
134+
*/
135+
private boolean isExcludedPath(String requestURI) {
136+
return NO_AUTH_URLS.stream().anyMatch(pattern -> pathMatcher.match(pattern, requestURI));
137+
}
138+
139+
/**
140+
* 인증 실패 응답을 클라이언트에 전송합니다.
141+
* <p>
142+
* 공통 응답 포맷 형식을 사용합니다.
143+
*
144+
* @param response
145+
* @throws IOException
146+
*/
147+
private void sendUnauthorized(HttpServletResponse response) throws IOException {
148+
SecurityContextHolder.clearContext(); // 인증 정보 제거
149+
ErrorResponseUtil.writeErrorResponse(response, ErrorStatus.AUTH_INVALID_TOKEN);
150+
}
151+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.synergyx.trading.config.security;
2+
3+
public enum JwtValidationType {
4+
VALID_JWT, // 유효한 JWT
5+
INVALID_JWT_SIGNATURE, // 유효하지 않은 서명
6+
INVALID_JWT_TOKEN, // 유효하지 않은 토큰
7+
EXPIRED_JWT_TOKEN, // 만료된 토큰
8+
UNSUPPORTED_JWT_TOKEN, // 지원하지 않는 형식의 토큰
9+
EMPTY_JWT // 빈 JWT
10+
}

src/main/java/com/synergyx/trading/config/security/SecurityConfig.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
77
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
88
import org.springframework.security.config.http.SessionCreationPolicy;
9+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
10+
import org.springframework.security.crypto.password.PasswordEncoder;
911
import org.springframework.security.web.SecurityFilterChain;
1012
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1113

@@ -14,10 +16,19 @@
1416
@EnableWebSecurity
1517
public class SecurityConfig {
1618

19+
private final JwtAuthenticationFilter jwtAuthenticationFilter;
20+
private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
21+
private final CustomAccessDeniedHandler customAccessDeniedHandler;
22+
23+
@Bean
24+
public PasswordEncoder passwordEncoder() {
25+
return new BCryptPasswordEncoder();
26+
}
27+
1728
private static final String[] AUTH_WHITELIST = {
1829
"/auth/signup",
1930
"/auth/login",
20-
"/**"
31+
"/test/**"
2132
};
2233

2334
private static final String[] SWAGGER_WHITELIST = {
@@ -34,12 +45,15 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
3445
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
3546
)
3647
.exceptionHandling(exception -> {
48+
exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint);
49+
exception.accessDeniedHandler(customAccessDeniedHandler);
3750
})
3851
.authorizeHttpRequests(auth -> {
3952
auth.requestMatchers(AUTH_WHITELIST).permitAll();
4053
auth.requestMatchers(SWAGGER_WHITELIST).permitAll();
4154
auth.anyRequest().authenticated();
42-
});
55+
})
56+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
4357

4458
return http.build();
4559
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.synergyx.trading.config.security;
2+
3+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
4+
import org.springframework.security.core.GrantedAuthority;
5+
6+
import java.util.Collection;
7+
8+
public class UserAuthentication extends UsernamePasswordAuthenticationToken {
9+
10+
/**
11+
* 인증된 사용자를 생성합니다.
12+
*
13+
* @param userId 사용자 ID (Principal로 사용)
14+
* @param credentials 인증 자격 정보 (일반적으로 null)
15+
* @param authorities 권한 목록 (ROLE_USER 등)
16+
*/
17+
public UserAuthentication(Long userId,
18+
Object credentials,
19+
Collection<? extends GrantedAuthority> authorities) {
20+
super(userId, credentials, authorities);
21+
}
22+
23+
/**
24+
* 인증된 사용자 ID를 반환합니다.
25+
*
26+
* @return userId (Long)
27+
* @throws IllegalStateException principal이 Long이 아닐 경우
28+
*/
29+
public Long getUserId() {
30+
Object principal = getPrincipal();
31+
if (principal instanceof Long) {
32+
return (Long) principal;
33+
}
34+
throw new IllegalStateException("Authentication principal is not a Long (userId).");
35+
}
36+
}

0 commit comments

Comments
 (0)