Skip to content
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] 김준수 미션 제출합니다. #31

Open
wants to merge 8 commits into
base: gogo1414
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## 요구사항 분석
### 1단계 - 로그인
- 로그인 기능을 구현하세요.
- 로그인 후 Cookie를 이용하여 사용자의 정보를 조회하는 API를 구현하세요.
#### 로그인 기능
- 아래의 request와 response 요구사항에 따라 /login에 email, password 값을 body에 포함하세요.
- 응답에 Cookie에 "token"값으로 토큰이 포함되도록 하세요.
#### 인증 정보 조회
- 상단바 우측 로그인 상태를 표현해주기 위해 사용자의 정보를 조회하는 API를 구현하세요.
- Cookie를 이용하여 로그인 사용자의 정보확인하세요.

### 2단계 - 로그인 리팩터링
- 사용자의 정보를 조회하는 로직을 리팩터링 합니다.
- 예약 생성 API 및 기능을 리팩터링 합니다.
#### 로그인 리팩터링
- Cookie에 담긴 인증 정보를 이용해서 멤버 객체를 만드는 로직을 분리합니다.
- HandlerMethodArgumentResolver을 활용하면 회원정보를 객체를 컨트롤러 메서드에 주입할 수 있습니다.
#### 예약 생성 기능 변경
- 예약 생성 시 ReservationReqeust의 name이 없는 경우 Cookie에 담긴 정보를 활용하도록 리팩터링 합니다.
- ReservationReqeust에 name값이 있으면 name으로 Member를 찾고
- 없으며 로그인 정보를 활용해서 Member를 찾도록 수정합니다.

### 3단계 - 관리자 기능
- 어드민 페이지 진입은 admin권한이 있는 사람만 할 수 있도록 제한하세요.
- HandlerInterceptor를 활용하여 권한이 없는 경우 401코드를 응답하세요.
31 changes: 31 additions & 0 deletions src/main/java/roomescape/auth/AuthAdminInteceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package roomescape.auth;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.infrastructure.JwtTokenUtil;
import roomescape.member.Member;
import roomescape.member.MemberService;

@Component
public class AuthAdminInteceptor implements HandlerInterceptor {
private MemberService memberService;
private JwtTokenUtil jwtTokenUtil;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final로 처리하는 것이 더 좋아보여요!
[Java] final 을 알아보자

Suggested change
private MemberService memberService;
private JwtTokenUtil jwtTokenUtil;
private final MemberService memberService;
private final JwtTokenUtil jwtTokenUtil;


public AuthAdminInteceptor(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;
}
Comment on lines +16 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Autowired 어노테이션을 사용하여 의존성 주입을 명확하게 하는 것도 가독성에 좋을 거 같네요!

Suggested change
public AuthAdminInteceptor(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;
}
@Autowired
public AuthAdminInteceptor(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;


@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Long memberId = jwtTokenUtil.getPayload(request);
Member member = memberService.findMemberById(memberId);
if (member == null || !member.getRole().equals("ADMIN")) {
response.setStatus(401);
return false;
}
return true;
}
}
29 changes: 29 additions & 0 deletions src/main/java/roomescape/auth/AuthConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package roomescape.auth;

import java.util.List;
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 roomescape.infrastructure.LoginMemberArgumentResolver;

@Configuration
public class AuthConfig implements WebMvcConfigurer {
private final LoginMemberArgumentResolver loginMemberArgumentResolver;
private final AuthAdminInteceptor authAdminInteceptor;

public AuthConfig(LoginMemberArgumentResolver loginMemberArgumentResolver, AuthAdminInteceptor authAdminInteceptor) {
this.loginMemberArgumentResolver = loginMemberArgumentResolver;
this.authAdminInteceptor = authAdminInteceptor;
}
Comment on lines +15 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Autowired 어노테이션을 사용하여 의존성 주입을 명확하게 하는 것도 가독성에 좋을 거 같네요!

Suggested change
public AuthConfig(LoginMemberArgumentResolver loginMemberArgumentResolver, AuthAdminInteceptor authAdminInteceptor) {
this.loginMemberArgumentResolver = loginMemberArgumentResolver;
this.authAdminInteceptor = authAdminInteceptor;
}
@Autowired
public AuthConfig(LoginMemberArgumentResolver loginMemberArgumentResolver, AuthAdminInteceptor authAdminInteceptor) {
this.loginMemberArgumentResolver = loginMemberArgumentResolver;
this.authAdminInteceptor = authAdminInteceptor;
}


@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authAdminInteceptor);
}
}
41 changes: 41 additions & 0 deletions src/main/java/roomescape/auth/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package roomescape.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import roomescape.infrastructure.JwtTokenUtil;
import roomescape.member.Member;
import roomescape.member.MemberDao;
import roomescape.token.TokenRequest;
import roomescape.token.TokenResponse;

@Service
public class AuthService {
private JwtTokenUtil jwtTokenUtil;
private MemberDao memberDao;

public AuthService(JwtTokenUtil jwtTokenUtil, MemberDao memberDao) {
this.jwtTokenUtil = jwtTokenUtil;
this.memberDao = memberDao;
}

public Member checkInvalidLogin(String principal, String credentials) {
Member member = null;
try {
member = memberDao.findByEmailAndPassword(principal, credentials);
} catch(AuthorizationException e) {
e.printStackTrace();
}
return member;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"존재하지 않는 email 또는 password 입니다." 이런식으로 예외 처리하면 나중에 더 편할 것 같아요!

Copy link
Author

@gogo1414 gogo1414 Jun 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 처리를 꼼꼼하게 못했는데 알려주셔서 감사해요👍


public Long findMemberIdByToken(HttpServletRequest request) {
return jwtTokenUtil.getPayload(request);
}

public TokenResponse createToken(TokenRequest tokenRequest) {
Member member = checkInvalidLogin(tokenRequest.getEmail(), tokenRequest.getPassword());
String accessToken = jwtTokenUtil.createToken(member);
return new TokenResponse(accessToken);
}
}
14 changes: 14 additions & 0 deletions src/main/java/roomescape/auth/AuthorizationException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package roomescape.auth;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class AuthorizationException extends RuntimeException {
public AuthorizationException() {
}

public AuthorizationException(String message) {
super(message);
}
}
65 changes: 65 additions & 0 deletions src/main/java/roomescape/infrastructure/JwtTokenUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package roomescape.infrastructure;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import roomescape.member.Member;

@Component
public class JwtTokenUtil {
@Value("${security.jwt.token.secret-key}")
private String secretKey;
@Value("${security.jwt.token.expire-length}")
private long validityInMilliseconds;

public String createToken(Member member) {
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.setSubject(member.getId().toString())
.claim("email", member.getEmail())
.claim("name", member.getName())
.claim("role", member.getRole())
.setIssuedAt(now)
.setExpiration(validity)
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.compact();
}

public Long getPayload(HttpServletRequest request) {
String token = extractTokenFromCookie(request);
return Long.valueOf(Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody().getSubject());
}

public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에랑 통일되게 Jwts.parserBuilder()를 사용하는 것이 어떨까요?

Suggested change
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

통일성이 떨어지고 있었네요! 해당부분 발견해서 바로 수정했어요!


return !claims.getBody().getExpiration().before(new Date());
} catch (JwtException | IllegalArgumentException e) {
return false;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 발생시 로그를 남기는 것이 나중에 원인을 찾을 때도 좋을 것 같아요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제가 일어나면 어디서 발생했는지 찾을 때 도움이 되겠네요!
해당 부분 참고해서 수정해보도록 하겠습니다.

}
}

private String extractTokenFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("token")) {
return cookie.getValue();
}
}
return "";
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cookies가 null일 경우 예외처리를 해주는 것도 좋을 것 같아요!

Copy link
Author

@gogo1414 gogo1414 Jun 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사해요! 코드의 인덴트도 2가 넘어가서 같이 수정 진행했습니다ㅎㅎ

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package roomescape.infrastructure;

import jakarta.servlet.http.HttpServletRequest;
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.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import roomescape.member.LoginMember;
import roomescape.member.Member;
import roomescape.member.MemberService;

@Component
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
private MemberService memberService;
private JwtTokenUtil jwtTokenUtil;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final로 처리하는 것이 더 좋아보여요!
[Java] final 을 알아보자

Suggested change
private MemberService memberService;
private JwtTokenUtil jwtTokenUtil;
private final MemberService memberService;
private finla JwtTokenUtil jwtTokenUtil;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정했습니다!


public LoginMemberArgumentResolver(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Autowired 어노테이션과 함께 생성자 주입을 하여 가독성을 좋게 할 수 있어요!

Suggested change
public LoginMemberArgumentResolver(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;
}
@Autowired
public LoginMemberArgumentResolver(MemberService memberService, JwtTokenUtil jwtTokenUtil) {
this.memberService = memberService;
this.jwtTokenUtil = jwtTokenUtil;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Autowired로 변경하려고 하니 생성자를 통한 주입으로 변경을 스프링에서 추천하길래 잠깐 찾아봤어요! 공유해드리면 좋을 것 같아서 적어봅니다!

@Autowired 어노테이션을 사용하면 의존성 주입이 가능하지만, Spring에서는 생성자를 통한 의존성 주입을 권장합니다. 생성자 주입이 권장되는 이유는 여러 가지가 있습니다:

  1. 불변성: 생성자를 통해 주입된 의존성은 변경될 수 없습니다. 이는 객체의 상태를 더 안전하게 만듭니다.
  2. 테스트 용이성: 생성자 주입은 단위 테스트 작성 시 더 쉽고, Mockito와 같은 라이브러리로 주입할 의존성을 설정하기가 수월합니다.
  3. 순환 참조 방지: 생성자 주입은 컴파일 타임에 순환 의존성을 확인할 수 있도록 해주므로, 런타임에 발생할 수 있는 순환 참조 문제를 방지합니다.
  4. 명확한 의존성: 생성자를 통해 의존성을 주입하면, 해당 클래스가 어떤 의존성을 필요로 하는지 명확하게 알 수 있습니다.
💡 Spring Boot 2.0 이후, `@Autowired`를 필드에 사용하는 것보다 생성자 주입을 더 권장하고 있습니다. 그래서 IDE나 코드 검사 도구가 생성자를 사용하라는 경고를 표시하는 것입니다.


@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(LoginMember.class);
}

@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) webRequest.getNativeRequest();

Long memberId = jwtTokenUtil.getPayload(httpServletRequest);
Member member = memberService.findMemberById(memberId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰이 없거나 회원 정보를 찾을 수 없는 경우 예외 처리를 해주면 좋을 거 같아요!

Suggested change
Long memberId = jwtTokenUtil.getPayload(httpServletRequest);
Member member = memberService.findMemberById(memberId);
Long memberId = jwtTokenUtil.getPayload(httpServletRequest);
if (memberId == null) {
throw new IllegalArgumentException("Invalid or missing JWT token");
}
Member member = memberService.findMemberById(memberId);
if (member == null) {
throw new IllegalArgumentException("Member not found for id: " + memberId);
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 처리 진행했습니다!


return new LoginMember(member.getId(), member.getName(), member.getEmail(), member.getRole());
}
}
31 changes: 31 additions & 0 deletions src/main/java/roomescape/member/LoginMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package roomescape.member;

public class LoginMember {
private Long id;
private String name;
private String email;
private String role;

public LoginMember(Long id, String name, String email, String role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}

public Long getId() {
return id;
}

public String getName() {
return name;
}

public String getEmail() {
return email;
}

public String getRole() {
return role;
}
}
32 changes: 31 additions & 1 deletion src/main/java/roomescape/member/MemberController.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import roomescape.auth.AuthService;
import roomescape.token.TokenRequest;
import roomescape.token.TokenResponse;

@RestController
public class MemberController {
private MemberService memberService;
private AuthService authService;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에서 알려드린 거 처럼 final로 선언하시는 것이 좋아보여요!

Suggested change
private MemberService memberService;
private AuthService authService;
private final MemberService memberService;
private final AuthService authService;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 진행 했어요!


public MemberController(MemberService memberService) {
public MemberController(MemberService memberService, AuthService authService) {
this.memberService = memberService;
this.authService = authService;
}

@PostMapping("/members")
Expand All @@ -25,6 +30,31 @@ public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) {
return ResponseEntity.created(URI.create("/members/" + member.getId())).body(member);
}

@PostMapping("/login")
public ResponseEntity login(@RequestBody TokenRequest request, HttpServletResponse response) {
TokenResponse tokenResponse = authService.createToken(request);

Cookie cookie = new Cookie("token", tokenResponse.getAccessToken());
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);

// HttpHeaders 객체 생성
// HttpHeaders headers = new HttpHeaders();
// headers.add("Set-Cookie", "token=" + tokenResponse.getAccessToken() + "; Path=/; HttpOnly");

return ResponseEntity.ok().build();
}

@GetMapping("/login/check")
public ResponseEntity<MemberResponse> checkLogin(HttpServletRequest request) {
Long memberId = authService.findMemberIdByToken(request);
Member member = memberService.findMemberById(memberId);
MemberResponse response = new MemberResponse(member.getName());
return ResponseEntity.ok()
.body(response);
}

@PostMapping("/logout")
public ResponseEntity logout(HttpServletResponse response) {
Cookie cookie = new Cookie("token", "");
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/roomescape/member/MemberDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,17 @@ public Member findByName(String name) {
name
);
}

public Member findById(Long id) {
return jdbcTemplate.queryForObject(
"SELECT id, name, email, role FROM member WHERE id = ?",
(rs, rowNum) -> new Member(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("role")
),
id
);
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/member/MemberResponse.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package roomescape.member;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class MemberResponse {
private Long id;
private String name;
private String email;

public MemberResponse(String name) {
this.name = name;
}

public MemberResponse(Long id, String name, String email) {
this.id = id;
this.name = name;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/roomescape/member/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ public MemberResponse createMember(MemberRequest memberRequest) {
Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER"));
return new MemberResponse(member.getId(), member.getName(), member.getEmail());
}

public Member findMemberById(Long memberId) {
return memberDao.findById(memberId);
}
}
Loading