-
Notifications
You must be signed in to change notification settings - Fork 50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Spring MVC(인증)] 안금서 미션 제출합니다. #102
base: goldm0ng
Are you sure you want to change the base?
Changes from all commits
5913de1
67da617
53828d8
0ec5186
ebf6407
22f9e30
347d0c7
2a96836
91bf7bf
16e217b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package roomescape.authentication; | ||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
import org.springframework.web.servlet.HandlerInterceptor; | ||
import roomescape.jwt.JwtProvider; | ||
import roomescape.jwt.JwtResponse; | ||
import roomescape.jwt.JwtUtils; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class AuthAdminRoleInterceptor implements HandlerInterceptor { | ||
|
||
private final JwtProvider jwtProvider; | ||
|
||
@Override | ||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { | ||
JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies()); | ||
if (jwtResponse.accessToken() == null || !isAdmin(jwtResponse.accessToken())) { | ||
response.setStatus(401); | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
private boolean isAdmin(String token) { | ||
MemberAuthInfo memberAuthInfo = JwtUtils.extractMemberAuthInfoFromToken(token); | ||
return "ADMIN".equals(memberAuthInfo.role()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package roomescape.authentication; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | ||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
||
import java.util.List; | ||
|
||
@Configuration | ||
@RequiredArgsConstructor | ||
public class AuthenticationConfig implements WebMvcConfigurer { | ||
|
||
private final LoginMemberArgumentResolver loginMemberArgumentResolver; | ||
private final AuthAdminRoleInterceptor authAdminRoleInterceptor; | ||
|
||
@Override | ||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
resolvers.add(loginMemberArgumentResolver); | ||
} | ||
|
||
@Override | ||
public void addInterceptors(InterceptorRegistry registry) { | ||
registry.addInterceptor(authAdminRoleInterceptor) | ||
.addPathPatterns("/admin/**"); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package roomescape.authentication; | ||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.core.MethodParameter; | ||
import org.springframework.stereotype.Component; | ||
import org.springframework.web.bind.support.WebDataBinderFactory; | ||
import org.springframework.web.context.request.NativeWebRequest; | ||
import org.springframework.web.context.request.ServletWebRequest; | ||
import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
import org.springframework.web.method.support.ModelAndViewContainer; | ||
import roomescape.jwt.JwtProvider; | ||
import roomescape.jwt.JwtResponse; | ||
import roomescape.jwt.JwtUtils; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { | ||
|
||
private final JwtProvider jwtProvider; | ||
|
||
@Override | ||
public boolean supportsParameter(MethodParameter parameter) { | ||
return parameter.getParameterType().equals(MemberAuthInfo.class); | ||
} | ||
|
||
@Override | ||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { | ||
HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); | ||
|
||
JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies()); | ||
if (jwtResponse.accessToken() == null) { | ||
return null; | ||
} | ||
|
||
return JwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package roomescape.authentication; | ||
|
||
public record MemberAuthInfo( | ||
String name, | ||
String role) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
package roomescape.exception; | ||
|
||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.ExceptionHandler; | ||
import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
||
@RestControllerAdvice | ||
public class GeneralExceptionHandler { | ||
|
||
@ExceptionHandler(MemberNotFoundException.class) | ||
public ResponseEntity<String> handleMemberNotFound(MemberNotFoundException e) { | ||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); | ||
} | ||
|
||
@ExceptionHandler(JwtValidationException.class) | ||
public ResponseEntity<String> handleJwtValidationException(JwtValidationException e) { | ||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); | ||
} | ||
|
||
@ExceptionHandler(JwtProviderException.class) | ||
public ResponseEntity<String> handleJwtProviderException(JwtProviderException e) { | ||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); | ||
} | ||
|
||
@ExceptionHandler(Exception.class) | ||
public ResponseEntity<String> handleGeneralException(Exception e) { | ||
e.printStackTrace(); | ||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package roomescape.exception; | ||
|
||
public class JwtProviderException extends RuntimeException { | ||
public JwtProviderException(String message) { | ||
super(message); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package roomescape.exception; | ||
|
||
public class JwtValidationException extends RuntimeException { | ||
public JwtValidationException(String message) { | ||
super(message); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package roomescape.exception; | ||
|
||
public class MemberNotFoundException extends RuntimeException { | ||
public MemberNotFoundException(String message) { | ||
super(message); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package roomescape.exception; | ||
|
||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.web.bind.annotation.ControllerAdvice; | ||
import org.springframework.web.bind.annotation.ExceptionHandler; | ||
import roomescape.PageController; | ||
|
||
@Slf4j | ||
@ControllerAdvice(assignableTypes = PageController.class) | ||
public class PageExceptionHandler { | ||
@ExceptionHandler(Exception.class) | ||
public String handleException(Exception e) { | ||
log.error("error: " + e.getMessage()); | ||
return "error/500"; //view 렌더링 페이지는 만들지 않음 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
package roomescape.jwt; | ||
|
||
import io.jsonwebtoken.JwtException; | ||
import io.jsonwebtoken.Jwts; | ||
import io.jsonwebtoken.security.Keys; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.stereotype.Component; | ||
import roomescape.exception.JwtProviderException; | ||
import roomescape.member.Member; | ||
|
||
@Component | ||
public class JwtProvider { | ||
|
||
@Value("${roomescape.auth.jwt.secret}") | ||
private String secretKey; | ||
|
||
public JwtResponse createAccessToken(Member member) { | ||
try { | ||
String accessToken = Jwts.builder() | ||
.setSubject(member.getId().toString()) | ||
.claim("name", member.getName()) | ||
.claim("role", member.getRole()) | ||
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes())) | ||
.compact(); | ||
|
||
return new JwtResponse(accessToken); | ||
} catch (JwtException e) { | ||
throw new JwtProviderException("JWT 생성에 실패하였습니다."); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이 코드는 에러 추적의 관점에서 문제가 하나 있습니다. 어떤 문제가 있을까요? |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package roomescape.jwt; | ||
|
||
public record JwtResponse( | ||
String accessToken | ||
) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
package roomescape.jwt; | ||
|
||
import io.jsonwebtoken.Claims; | ||
import io.jsonwebtoken.JwtException; | ||
import io.jsonwebtoken.Jwts; | ||
import io.jsonwebtoken.security.Keys; | ||
import jakarta.servlet.http.Cookie; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.stereotype.Component; | ||
import roomescape.authentication.MemberAuthInfo; | ||
import roomescape.exception.JwtValidationException; | ||
|
||
import java.util.Arrays; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class JwtUtils { | ||
|
||
private static String secretKey; | ||
|
||
@Value("${roomescape.auth.jwt.secret}") | ||
public void setSecretKey(String secretKey) { | ||
JwtUtils.secretKey = secretKey; | ||
} | ||
|
||
public static MemberAuthInfo extractMemberAuthInfoFromToken(String token) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JwtUtils의 메서드는 static 메서드인데 secretKey라는 인스턴스 변수를 필요로 했습니다. 작성하시면서도 느끼셨겠지만 static 메서드에 주위 환경을 끼워맞추는 과정에서 점점 찜찜하고 부자연스러운 구조가 되었습니다. 자바의 정석 서적을 집필하신 멋쟁이 강사님께서 static 메서드에 대해 설명해주신 영상이 있습니다. 위 영상을 참고해서 아래 두 질문의 답을 고민해주셨음 좋겠습니다.
|
||
if (token == null || token.isEmpty()) { | ||
throw new JwtValidationException("토큰이 존재하지 않습니다."); | ||
} | ||
|
||
try { | ||
Claims claims = Jwts.parserBuilder() | ||
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes())) | ||
.build() | ||
.parseClaimsJws(token) | ||
.getBody(); | ||
|
||
String name = claims.get("name", String.class); | ||
String role = claims.get("role", String.class); | ||
|
||
return new MemberAuthInfo(name, role); | ||
} catch (JwtException e) { | ||
throw new JwtValidationException("유효하지 않은 JWT 토큰입니다."); | ||
} | ||
} | ||
|
||
public static JwtResponse extractTokenFromCookie(Cookie[] cookies) { | ||
if (cookies == null) { | ||
throw new JwtValidationException("쿠키가 존재하지 않습니다."); | ||
} | ||
|
||
try { | ||
String accessToken = Arrays.stream(cookies) | ||
.filter(cookie -> cookie.getName().equals("token")) | ||
.map(Cookie::getValue) | ||
.findFirst() | ||
.orElseThrow(() -> new JwtValidationException("토큰이 존재하지 않습니다.")); | ||
|
||
return new JwtResponse(accessToken); | ||
} catch (Exception e) { | ||
throw new JwtValidationException("쿠키에서 토큰 추출 중 오류가 발생했습니다."); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
package roomescape.login; | ||
|
||
public record LoginCheckResponse(String name) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package roomescape.login; | ||
|
||
import jakarta.servlet.http.Cookie; | ||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import jakarta.validation.Valid; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.GetMapping; | ||
import org.springframework.web.bind.annotation.PostMapping; | ||
import org.springframework.web.bind.annotation.RequestBody; | ||
import org.springframework.web.bind.annotation.RestController; | ||
import roomescape.authentication.MemberAuthInfo; | ||
import roomescape.jwt.JwtResponse; | ||
import roomescape.jwt.JwtUtils; | ||
|
||
@RestController | ||
@RequiredArgsConstructor | ||
public class LoginController { | ||
|
||
private final LoginService loginService; | ||
|
||
@PostMapping("/login") | ||
public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest loginRequest, HttpServletResponse response) { | ||
JwtResponse jwtResponse = loginService.login(loginRequest); | ||
|
||
Cookie cookie = new Cookie("token", jwtResponse.accessToken()); | ||
cookie.setHttpOnly(true); | ||
cookie.setPath("/"); | ||
response.addCookie(cookie); | ||
|
||
return ResponseEntity.ok().build(); | ||
} | ||
|
||
@GetMapping("/login/check") | ||
public ResponseEntity<LoginCheckResponse> checkLogin(HttpServletRequest request) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 일반적으로 컨트롤러의 반환 타입으로 ResponseEntity<>를 사용하면
그런데 현재 컨트롤러 구조에서는 그렇다면 LoginController 자체는 httpStatus를 분기하지 않고 언제나 ok를 응답하는데, |
||
JwtResponse jwtResponse = JwtUtils.extractTokenFromCookie(request.getCookies()); | ||
MemberAuthInfo memberAuthInfo = JwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken()); | ||
LoginCheckResponse loginCheckResponse = loginService.checkLogin(memberAuthInfo); | ||
|
||
return ResponseEntity.ok(loginCheckResponse); | ||
} | ||
|
||
@PostMapping("/logout") | ||
public ResponseEntity logout(HttpServletResponse response) { | ||
Cookie cookie = new Cookie("token", ""); | ||
cookie.setHttpOnly(true); | ||
cookie.setPath("/"); | ||
cookie.setMaxAge(0); | ||
response.addCookie(cookie); | ||
|
||
return ResponseEntity.ok().build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package roomescape.login; | ||
|
||
import jakarta.validation.constraints.NotEmpty; | ||
import lombok.Data; | ||
|
||
@Data | ||
public class LoginRequest { | ||
|
||
@NotEmpty | ||
private String email; | ||
|
||
@NotEmpty | ||
private String password; | ||
|
||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 친구는 record 대신 @DaTa를 사용하신 이유가 있나요? :D |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
금서님께서 'login', 'authentication', 'jwt' 패키지를 구분하신 기준이 궁금합니다.
기본 제공된 코드의 구조에 맞추어서 도메인 별로 패키지를 구분하신 것 같은데요,
얼핏 생각하기에 login과 authentication은 뜻 차이가 거의 없고,
jwt는 인증의 수단 중 하나이니 authentication에 포함되는 관계가 아닌가- 라는 생각이 들었어요.