Skip to content

Commit 4cca13f

Browse files
authored
Merge pull request #4 from WhosInRoom/feat/#3-Jwt-login
[Feat] Jwt를 이용한 로그인 기능 구현
2 parents 9441e7b + 266bc95 commit 4cca13f

35 files changed

Lines changed: 1336 additions & 16 deletions

build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ dependencies {
4848
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
4949

5050
// JWT -> 원하는 버전 사용
51-
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
52-
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
53-
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
51+
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
52+
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
53+
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
5454

5555
// Redis
5656
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

src/main/java/com/WhoIsRoom/WhoIs_Server/WhoIsServerApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
56

7+
@EnableJpaAuditing
68
@SpringBootApplication
79
public class WhoIsServerApplication {
810

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.controller;
2+
3+
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
4+
import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseResponse;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
@Slf4j
15+
@RestController
16+
@RequiredArgsConstructor
17+
@RequestMapping("/api/auth")
18+
public class AuthController {
19+
20+
private final JwtService jwtService;
21+
22+
@PostMapping("/logout")
23+
public BaseResponse<Void> logout(HttpServletRequest request){
24+
jwtService.logout(request);
25+
return BaseResponse.ok(null);
26+
}
27+
28+
@PostMapping("/reissue")
29+
public BaseResponse<Void> reissueTokens(HttpServletRequest request, HttpServletResponse response) {
30+
jwtService.reissueTokens(request, response);
31+
return BaseResponse.ok(null);
32+
}
33+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request;
2+
3+
import lombok.AccessLevel;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
11+
public class LoginRequest {
12+
private String email;
13+
private String password;
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.dto.response;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@Builder
8+
public class LoginResponse {
9+
private String accessToken;
10+
private String refreshToken;
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.exception;
2+
3+
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
4+
import lombok.Getter;
5+
import org.springframework.security.core.AuthenticationException;
6+
7+
@Getter
8+
public class CustomAuthenticationException extends AuthenticationException {
9+
private final ErrorCode errorCode;
10+
11+
public CustomAuthenticationException(ErrorCode errorCode) {
12+
super(errorCode.getMessage());
13+
this.errorCode = errorCode;
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.exception;
2+
3+
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
4+
import io.jsonwebtoken.JwtException;
5+
import lombok.Getter;
6+
7+
@Getter
8+
public class CustomJwtException extends JwtException {
9+
private final ErrorCode errorCode;
10+
11+
public CustomJwtException(ErrorCode errorCode) {
12+
super(errorCode.getMessage());
13+
this.errorCode = errorCode;
14+
}
15+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.filter;
2+
3+
import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.LoginRequest;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.security.authentication.AuthenticationManager;
9+
import org.springframework.security.authentication.AuthenticationServiceException;
10+
import org.springframework.security.authentication.BadCredentialsException;
11+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
12+
import org.springframework.security.core.Authentication;
13+
import org.springframework.security.core.AuthenticationException;
14+
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
15+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
16+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
17+
18+
import java.io.IOException;
19+
20+
@Slf4j
21+
public class CustomLoginFilter extends UsernamePasswordAuthenticationFilter {
22+
23+
private final ObjectMapper objectMapper;
24+
25+
public CustomLoginFilter(AuthenticationManager authenticationManager,
26+
ObjectMapper objectMapper,
27+
AuthenticationSuccessHandler successHandler,
28+
AuthenticationFailureHandler failureHandler) {
29+
this.objectMapper = objectMapper;
30+
super.setFilterProcessesUrl("/api/auth/login");
31+
super.setAuthenticationManager(authenticationManager);
32+
super.setAuthenticationSuccessHandler(successHandler);
33+
super.setAuthenticationFailureHandler(failureHandler);
34+
}
35+
36+
@Override
37+
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
38+
throws AuthenticationException {
39+
40+
log.info("=== Login Filter (JSON only) 진입 ===");
41+
42+
if (!"POST".equalsIgnoreCase(request.getMethod())) {
43+
throw new AuthenticationServiceException("지원하지 않는 HTTP 메서드입니다.");
44+
}
45+
46+
String contentType = request.getContentType();
47+
if (contentType == null || !contentType.toLowerCase().contains("application/json")) {
48+
throw new AuthenticationServiceException("Content-Type application/json 만 허용됩니다.");
49+
}
50+
51+
LoginRequest login;
52+
try {
53+
login = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
54+
} catch (IOException e) {
55+
throw new AuthenticationServiceException("JSON 파싱 실패", e);
56+
}
57+
58+
String email = login.getEmail();
59+
String password = login.getPassword();
60+
61+
if (email == null || email.isBlank() || password == null || password.isBlank()) {
62+
throw new BadCredentialsException("이메일/비밀번호가 비어있습니다.");
63+
}
64+
65+
UsernamePasswordAuthenticationToken authToken =
66+
new UsernamePasswordAuthenticationToken(email.trim(), password);
67+
68+
return this.getAuthenticationManager().authenticate(authToken);
69+
}
70+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.filter;
2+
3+
import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
4+
import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomJwtException;
5+
import com.WhoIsRoom.WhoIs_Server.domain.auth.model.UserPrincipal;
6+
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
7+
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.JwtUtil;
8+
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
9+
import io.jsonwebtoken.JwtException;
10+
import jakarta.servlet.FilterChain;
11+
import jakarta.servlet.ServletException;
12+
import jakarta.servlet.http.HttpServletRequest;
13+
import jakarta.servlet.http.HttpServletResponse;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
17+
import org.springframework.security.authentication.BadCredentialsException;
18+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
19+
import org.springframework.security.core.Authentication;
20+
import org.springframework.security.core.GrantedAuthority;
21+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
22+
import org.springframework.security.core.context.SecurityContextHolder;
23+
import org.springframework.stereotype.Component;
24+
import org.springframework.util.AntPathMatcher;
25+
import org.springframework.web.filter.GenericFilterBean;
26+
import org.springframework.web.filter.OncePerRequestFilter;
27+
28+
import javax.security.sasl.AuthenticationException;
29+
import java.io.IOException;
30+
import java.util.Arrays;
31+
import java.util.List;
32+
import java.util.Optional;
33+
34+
@Slf4j
35+
@Component
36+
@RequiredArgsConstructor
37+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
38+
private final JwtUtil jwtUtil;
39+
private final JwtService jwtService;
40+
41+
// 인증을 안해도 되니 토큰이 필요없는 URL들 (에러: 로그인이 필요합니다)
42+
public final static List<String> PASS_URIS = Arrays.asList(
43+
"/api/users/signup",
44+
"/api/auth/**"
45+
);
46+
47+
private static final AntPathMatcher ANT = new AntPathMatcher();
48+
49+
@Override
50+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
51+
52+
if(isPassUri(request.getRequestURI())) {
53+
log.info("JWT Filter Passed (pass uri) : {}", request.getRequestURI());
54+
filterChain.doFilter(request, response);
55+
return;
56+
}
57+
58+
// 엑세스 토큰이 없으면 Authentication도 없음 -> EntryPoint (401)
59+
log.info("Request URI: {}", request.getRequestURI()); // 요청 URI 로깅
60+
String accessToken = jwtUtil.extractAccessToken(request)
61+
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED));
62+
63+
// 토큰 유효성 검사
64+
jwtUtil.validateToken(accessToken);
65+
66+
// 토큰 타입 검사
67+
if(!"access".equals(jwtUtil.getTokenType(accessToken))) {
68+
throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE);
69+
}
70+
71+
// 로그아웃 체크
72+
jwtService.checkLogout(accessToken);
73+
74+
// 권한 리스트 생성
75+
List<GrantedAuthority> authorities = Arrays.asList(new SimpleGrantedAuthority(jwtUtil.getRole(accessToken)));
76+
log.info("Granted Authorities : {}", authorities);
77+
UserPrincipal principal = new UserPrincipal(
78+
jwtUtil.getUserId(accessToken),
79+
jwtUtil.getName(accessToken),
80+
null, // 패스워드는 필요 없음
81+
jwtUtil.getProviderId(accessToken),
82+
authorities
83+
);
84+
log.info("UserPrincipal.userId: {}", principal.getUserId());
85+
log.info("UserPrincipal.nickName: {}", principal.getUsername());
86+
log.info("UserPrincipal.providerId: {}", principal.getProviderId());
87+
log.info("UserPrincipal.role: {}", principal.getAuthorities().stream().findFirst().get().toString());
88+
89+
Authentication authToken = null;
90+
if ("localhost".equals(principal.getProviderId())) {
91+
// 폼 로그인(자체 회원)
92+
authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
93+
}
94+
// else {
95+
// // 소셜 로그인
96+
// authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider);
97+
// }
98+
log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication());
99+
log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
100+
101+
log.info("JWT Filter Success : {}", request.getRequestURI());
102+
SecurityContextHolder.getContext().setAuthentication(authToken);
103+
filterChain.doFilter(request, response);
104+
}
105+
106+
private boolean isPassUri(String uri) {
107+
return PASS_URIS.stream().anyMatch(pattern -> ANT.match(pattern, uri));
108+
}
109+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.WhoIsRoom.WhoIs_Server.domain.auth.handler.exception;
2+
3+
import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
4+
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
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+
import java.util.Map;
16+
17+
import static com.WhoIsRoom.WhoIs_Server.domain.auth.util.SecurityErrorResponseUtil.setErrorResponse;
18+
19+
@Slf4j
20+
@Component
21+
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
22+
23+
@Override
24+
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException{
25+
26+
log.info("=== AccessDeniedHandler 진입 ===");
27+
28+
ErrorCode code = ErrorCode.SECURITY_ACCESS_DENIED;
29+
setErrorResponse(response, code);
30+
}
31+
}

0 commit comments

Comments
 (0)