-
Notifications
You must be signed in to change notification settings - Fork 56
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(인증)] 정상희 미션 제출합니다. #103
base: sangheejeong
Are you sure you want to change the base?
Changes from 7 commits
eac6692
e1b4ce3
0186fcf
769b6bf
9b180fb
2ebef37
f9c1a7d
2e0884e
0cfc7eb
ff3ad6a
75132bc
fde3d1d
e3ae6da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
# 🌀 Spring MVC (인증) | ||
|
||
# 1단계 | ||
___ | ||
## 로그인 | ||
#### 로그인 페이지 | ||
+ 이메일, 비밀번호 입력 | ||
#### 로그인 요청 | ||
+ 이메일, 비밀번호 -> 멤버 조회 | ||
+ 조회한 멤버로 토큰 발급 | ||
+ Cookie를 만들어 응답 | ||
#### 인증 정보 조회 | ||
+ Cookie -> 토큰 정보 추출 | ||
+ 멤버를 찾아서 응답 | ||
|
||
로그인 관련 API | ||
+ GET/login : 로그인 페이지 호출 | ||
+ POST/login : 로그인 요청 | ||
+ GET/login/check : 인증 정보 조회 | ||
|
||
# 2단계 | ||
___ | ||
## 로그인 리팩터링 | ||
#### HandlerMethodArgumentResolver | ||
+ 컨트롤러 메서드 파라미터로 자동 주입 | ||
|
||
## 예약 생성 기능 변경 | ||
+ 예약 : ReservationRequest(요청 DTO) | ||
-> name이 있으면 name으로 Member 찾기 | ||
-> name이 없으면 Cookie에 담긴 정보 활용 | ||
|
||
# 3단계 | ||
___ | ||
## 관리자 기능 | ||
+ admin 페이지 진입 (HandlerInterceptor 이용) | ||
-> 관리자 : 진입 가능 | ||
-> 관리자 X : 401 코드 응답 | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package roomescape.auth; | ||
|
||
public record AuthClaims( | ||
String name, | ||
String role | ||
) { | ||
} | ||
Comment on lines
+1
to
+7
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. [Comment] 🤔 AuthClaims는 어떤 역할을 하는 객체인가요? 이름으로 유추해봤을 땐, token payload에 들어갈 인증 정보로 판단이 되는데요. 지금 해당 객체가 사용되는 목적은 3가지 정도로 보여집니다.
각 역할이 충돌하는 바가 있다고 봐요. 2, 3번은 서버에서 회원정보를 파악하기 위해 만든 것입니다. 저는 1, 2, 3이 각각 다른 목적으로 사용된다고 보여져요. (최소한 1번과 3번은 달라야한다고 생각합니다.) 이 서로 다른 상황에서 같은 객체로 해결하려 했을 때 어떤 점이 문제가 생길지 고려해보면 좋겠습니다.
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. 미션을 진행할 때 1번에서 사용할 때 name만 반환하면 되는데 AuthClaims를 모두 반환하는 것이 맞는지에 대해 살짝 고민을 하긴 했었는데요 ㅎㅎ.. 이렇게 사용되는 목적을 직접 보니까 확실히 사용되는 부분이 다른 것이 느껴졌습니다. 사실 지금 JPA 미션 도중에 이 리뷰를 보러 왔는데 JPA 미션이 MVC 미션에서 변경을 하면서 진행을 하는 과정이다보니까 이전 미션의 코드에 영향을 받을 수 밖에 없더라구요.. 빠른 시일 내에 수정해서 올리겠습니다. |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,40 @@ | ||||||||||||||||||||
package roomescape.auth; | ||||||||||||||||||||
|
||||||||||||||||||||
import jakarta.servlet.http.Cookie; | ||||||||||||||||||||
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.member.MemberService; | ||||||||||||||||||||
|
||||||||||||||||||||
import java.util.Arrays; | ||||||||||||||||||||
|
||||||||||||||||||||
@Component | ||||||||||||||||||||
@RequiredArgsConstructor | ||||||||||||||||||||
public class AuthClaimsArgumentResolver implements HandlerMethodArgumentResolver { | ||||||||||||||||||||
|
||||||||||||||||||||
private final MemberService memberService; | ||||||||||||||||||||
|
||||||||||||||||||||
@Override | ||||||||||||||||||||
public boolean supportsParameter(MethodParameter parameter) { | ||||||||||||||||||||
return parameter.getParameterType().equals(AuthClaims.class); | ||||||||||||||||||||
} | ||||||||||||||||||||
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. [Approve] Spring을 학습하며 미션에 적용하는게 쉽지 않으실텐데 이렇게 특정 타입의 Argument를 처리할 수 있도록 등록하시다니 대단합니다. 이 방법도 좋은 방법이라고 생각하는데요. 보편적인 방법?전략?을 설명 드리면 ArgumentResolver를 등록할 때 커스텀 어노테이션을 정의하고 해당 어노테이션이 있는지 없는지 체크하는 경우가 많아요. 제 생각엔 특정 객체로 일치를 시키면 해당 객체는 컨트롤러에 아규먼트로 오게되면 그 리졸버를 무조건 타게 되겠지만, 저희가 애용하는
Suggested change
|
||||||||||||||||||||
|
||||||||||||||||||||
@Override | ||||||||||||||||||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { | ||||||||||||||||||||
HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); | ||||||||||||||||||||
String token = Arrays.stream(request.getCookies()) | ||||||||||||||||||||
.filter(cookie -> "token".equals(cookie.getName())) | ||||||||||||||||||||
.findFirst() | ||||||||||||||||||||
.map(Cookie::getValue) | ||||||||||||||||||||
.orElseThrow(() -> new IllegalArgumentException("토큰을 찾을 수 없습니다.")); | ||||||||||||||||||||
|
||||||||||||||||||||
return memberService.checkLogin(new AuthToken(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. [Comment] HttpRequest -> Cookie -> Jwt -> 인증정보 이렇게 꺼내는 동작이 AutInterceptor에도 동일하게 존재하는데요. |
||||||||||||||||||||
} | ||||||||||||||||||||
} | ||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package roomescape.auth; | ||
|
||
import jakarta.servlet.http.Cookie; | ||
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.member.MemberService; | ||
|
||
import java.util.Arrays; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class AuthRoleInterceptor implements HandlerInterceptor { | ||
private final MemberService memberService; | ||
|
||
@Override | ||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { | ||
String token = Arrays.stream(request.getCookies()) | ||
.filter(cookie -> "token".equals(cookie.getName())) | ||
.findFirst() | ||
.map(Cookie::getValue) | ||
.orElseThrow(() -> new IllegalArgumentException("토큰을 찾을 수 없습니다.")); | ||
|
||
AuthClaims userClaims = memberService.checkLogin(new AuthToken(token)); | ||
if (!userClaims.role().equals("ADMIN")) { | ||
response.setStatus(401); | ||
return false; | ||
} | ||
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. [Request Change] Oh NO... 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. 오 찾아보니까 말씀해주신 문제(ex : 역할 변경 request에서 잘못된 역할값이 들어왔을 때)를 방지하기 위해서 enum 타입을 활용하는 방법이 있네요 |
||
|
||
return true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package roomescape.auth; | ||
|
||
public record AuthToken( | ||
String token | ||
) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package roomescape.auth; | ||
|
||
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 AuthWebConfig implements WebMvcConfigurer { | ||
|
||
private final AuthClaimsArgumentResolver loginMemberArgumentResolver; | ||
private final AuthRoleInterceptor roleCheckInterceptor; | ||
|
||
@Override | ||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
resolvers.add(loginMemberArgumentResolver); | ||
} | ||
|
||
@Override | ||
public void addInterceptors(InterceptorRegistry registry) { | ||
registry.addInterceptor(roleCheckInterceptor).addPathPatterns("/admin/**"); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package roomescape.auth; | ||
|
||
import io.jsonwebtoken.Claims; | ||
import io.jsonwebtoken.Jwts; | ||
import io.jsonwebtoken.security.Keys; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.stereotype.Component; | ||
import roomescape.member.Member; | ||
|
||
@Component | ||
public class JWTUtils { | ||
|
||
@Value("${roomescape.auth.jwt.secret}") | ||
private String secretKey; | ||
|
||
public AuthToken createToken(Member member) { | ||
String accessToken = Jwts.builder() | ||
.setSubject(member.getId().toString()) | ||
.claim("name", member.getName()) | ||
.claim("role", member.getRole()) | ||
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes())) | ||
.compact(); | ||
Comment on lines
+24
to
+29
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. [Comment] 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.
|
||
|
||
return new AuthToken(accessToken); | ||
} | ||
|
||
public AuthClaims getClaimsFromToken(String token) { | ||
Claims claims = Jwts.parserBuilder() | ||
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes())) | ||
.build() | ||
.parseClaimsJws(token) | ||
.getBody(); | ||
|
||
return new AuthClaims(claims.get("name", String.class), claims.get("role", String.class)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package roomescape.member; | ||
|
||
public record LoginRequest( | ||
String email, | ||
String password | ||
) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,28 +1,42 @@ | ||
package roomescape.member; | ||
|
||
import jakarta.servlet.http.Cookie; | ||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.CookieValue; | ||
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.auth.AuthClaims; | ||
import roomescape.auth.AuthToken; | ||
|
||
import java.net.URI; | ||
|
||
@RestController | ||
@RequiredArgsConstructor | ||
public class MemberController { | ||
private MemberService memberService; | ||
|
||
public MemberController(MemberService memberService) { | ||
this.memberService = memberService; | ||
} | ||
private final MemberService memberService; | ||
|
||
@PostMapping("/members") | ||
public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) { | ||
MemberResponse member = memberService.createMember(memberRequest); | ||
return ResponseEntity.created(URI.create("/members/" + member.getId())).body(member); | ||
return ResponseEntity.created(URI.create("/members/" + member.id())).body(member); | ||
} | ||
|
||
@PostMapping("/login") | ||
public void login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { | ||
String token = memberService.getToken(loginRequest).token(); | ||
Cookie cookie = new Cookie("token", token); | ||
cookie.setHttpOnly(true); | ||
cookie.setPath("/"); | ||
response.addCookie(cookie); | ||
Comment on lines
+29
to
+33
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. [Approve] 이번 미션에서 아주 중요한 내용이라고 할 수 있겠죠. 쿠키가 무엇인지, 쿠키에 대해서 학습하시고, 각 옵션을 설정한 이유에 대해서 저한테 한번 설명해주시면 좋을 것 같아요! 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. 쿠키 : 사용 이유 : 31줄 : 쿠키 생성 key, value값 -> "token", 생성한 token값 오 사실 견본 코드의 내용이라서 한 번 검색만 해보고 넘겼었는데 이렇게 다시 정리하고 쿠키에 대해서 좀 더 자세히 찾아보니까 사용 목적에 대해 명확하게 알게 된 것 같아서 좋네요! |
||
} | ||
|
||
@GetMapping("/login/check") | ||
public AuthClaims checkLogin(@CookieValue("token") String token) { | ||
return memberService.checkLogin(new AuthToken(token)); | ||
} | ||
|
||
@PostMapping("/logout") | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,19 +1,10 @@ | ||
package roomescape.member; | ||
|
||
public class MemberRequest { | ||
private String name; | ||
private String email; | ||
private String password; | ||
|
||
public String getName() { | ||
return name; | ||
} | ||
|
||
public String getEmail() { | ||
return email; | ||
} | ||
|
||
public String getPassword() { | ||
return password; | ||
} | ||
public record MemberRequest( | ||
String name, | ||
String email, | ||
String password | ||
) { | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,8 @@ | ||
package roomescape.member; | ||
|
||
public class MemberResponse { | ||
private Long id; | ||
private String name; | ||
private String email; | ||
|
||
public MemberResponse(Long id, String name, String email) { | ||
this.id = id; | ||
this.name = name; | ||
this.email = email; | ||
} | ||
|
||
public Long getId() { | ||
return id; | ||
} | ||
|
||
public String getName() { | ||
return name; | ||
} | ||
|
||
public String getEmail() { | ||
return email; | ||
} | ||
public record MemberResponse( | ||
Long id, | ||
String name, | ||
String email | ||
) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,39 @@ | ||
package roomescape.member; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.dao.EmptyResultDataAccessException; | ||
import org.springframework.stereotype.Service; | ||
import roomescape.auth.AuthClaims; | ||
import roomescape.auth.AuthToken; | ||
import roomescape.auth.JWTUtils; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class MemberService { | ||
private MemberDao memberDao; | ||
|
||
public MemberService(MemberDao memberDao) { | ||
this.memberDao = memberDao; | ||
} | ||
private final MemberDao memberDao; | ||
private final JWTUtils jwtUtils; | ||
|
||
public MemberResponse createMember(MemberRequest memberRequest) { | ||
Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER")); | ||
Member member = memberDao.save(new Member(memberRequest.name(), memberRequest.email(), memberRequest.password(), "USER")); | ||
return new MemberResponse(member.getId(), member.getName(), member.getEmail()); | ||
} | ||
|
||
public AuthToken getToken(LoginRequest loginRequest) { | ||
try { | ||
Member member = memberDao.findByEmailAndPassword(loginRequest.email(), loginRequest.password()); | ||
return jwtUtils.createToken(member); | ||
} catch (EmptyResultDataAccessException e) { | ||
throw new IllegalArgumentException("로그인 정보가 잘못되었습니다."); | ||
} | ||
} | ||
|
||
public AuthClaims checkLogin(AuthToken userToken) { | ||
try { | ||
AuthClaims userClaims = jwtUtils.getClaimsFromToken(userToken.token()); | ||
memberDao.findByName(userClaims.name()); | ||
return userClaims; | ||
} catch (EmptyResultDataAccessException exception) { | ||
throw new IllegalArgumentException("존재하지 않는 회원입니다."); | ||
} | ||
} | ||
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. [Request Change] 이 두 로직에서 핸들링 되는 예외 상황은 아래와 같습니다.
IllegalArgumentException을 뱉어내고, 400 BAD 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.
|
||
} |
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.
[Approve]
깔끔한 요구사항 정리 감사합니다.