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 (인증)] 김진학 미션 제출합니다. #121

Open
wants to merge 14 commits into
base: iampingu99
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
32 changes: 32 additions & 0 deletions src/main/java/roomescape/auth/AdminInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package roomescape.auth;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AdminInterceptor implements HandlerInterceptor {

Choose a reason for hiding this comment

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

Interceptor 를 사용했네요.
Interceptor 와 Filter 의 차이점이 뭔가요?

private final AuthService authService;
private final AuthorizationExtractor authorizationExtractor;

public AdminInterceptor(AuthService authService, AuthorizationExtractor authorizationExtractor) {
this.authService = authService;
this.authorizationExtractor = authorizationExtractor;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {

String token = authorizationExtractor.extract(request);
LoginMember loginMember = authService.createAuthentication(token);

Choose a reason for hiding this comment

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

LoginMember.from() 부분에서 Claims 에 해당하는 값이 없으면 예외가 터질거 같은데 이 경우 어떻게 되나요?


if (!loginMember.isAdmin()) {
response.setStatus(401);
return false;
}

return true;
}
}
7 changes: 2 additions & 5 deletions src/main/java/roomescape/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
import io.jsonwebtoken.JwtException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpStatus;
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;
Expand Down Expand Up @@ -36,10 +34,9 @@ public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletR
}

@GetMapping("/login/check")
public ResponseEntity check(@CookieValue(name = TOKEN_COOKIE) String token) {
public ResponseEntity check(LoginMember loginMember) {
try {
Map<String, Object> claims = authService.extractClaims(token);
return ResponseEntity.ok().body(claims);
return ResponseEntity.ok().body(loginMember);
} catch (JwtException | IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token");
}
Expand Down
7 changes: 4 additions & 3 deletions src/main/java/roomescape/auth/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package roomescape.auth;

import java.util.Map;
import io.jsonwebtoken.Claims;
import org.springframework.stereotype.Service;
import roomescape.member.Member;
import roomescape.member.MemberDao;
Expand All @@ -20,7 +20,8 @@ public String createToken(String email, String password) {
return jwtTokenProvider.createToken(member);
}

public Map<String, Object> extractClaims(String token) {
return jwtTokenProvider.getClaims(token);
public LoginMember createAuthentication(String token) {
Claims claims = jwtTokenProvider.getClaims(token);
return LoginMember.from(claims);
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/auth/AuthorizationExtractor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.auth;

import jakarta.servlet.http.HttpServletRequest;

public interface AuthorizationExtractor {
String extract(HttpServletRequest request);
}
20 changes: 20 additions & 0 deletions src/main/java/roomescape/auth/CookieAuthorizationExtractor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package roomescape.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import org.springframework.stereotype.Component;

@Component
public class CookieAuthorizationExtractor implements AuthorizationExtractor {
private static final String AUTHORIZATION_NAME = "token";

@Override
public String extract(HttpServletRequest request) {
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(AUTHORIZATION_NAME))
.map(Cookie::getValue)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Empty cookie"));
}
}
9 changes: 5 additions & 4 deletions src/main/java/roomescape/auth/JwtTokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.util.Date;
import java.util.Map;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;
import roomescape.member.Member;
Expand All @@ -14,15 +13,17 @@ public class JwtTokenProvider {

private final SecretKey secretKey;
private final long validityInMilliseconds;
private final TimeProvider timeProvider;

public JwtTokenProvider(JwtProperties jwtProperties) {
public JwtTokenProvider(JwtProperties jwtProperties, TimeProvider timeProvider) {
this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes());
this.validityInMilliseconds = jwtProperties.getValidityInMilliseconds();
this.timeProvider = timeProvider;
}

public String createToken(Member member) {
Claims claims = Jwts.claims().setSubject(member.getEmail());
Date now = new Date();
Date now = timeProvider.now();
Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
Expand All @@ -35,7 +36,7 @@ public String createToken(Member member) {
.compact();
}

public Map<String, Object> getClaims(String token) {
public Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/roomescape/auth/LoginMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package roomescape.auth;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.jsonwebtoken.Claims;

public record LoginMember(
String email,
String name,
String role

Choose a reason for hiding this comment

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

클린코드 관점에서 Role 이라는 ENUM 을 만들어도 될 거 같네요.

isAdmin 역시도 Role 에서 메소드로 가지고 있고 LoginMember 는 호출만 해도 되겠네용

) {
public static LoginMember from(Claims claims) {
return new LoginMember(
claims.getSubject(),
claims.get("name", String.class),
claims.get("role", String.class));
}

@JsonIgnore
public boolean isAdmin() {
return this.role.equals("ADMIN");
}
}
32 changes: 32 additions & 0 deletions src/main/java/roomescape/auth/LoginMemberArgumentResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package roomescape.auth;

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;

@Component
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

Choose a reason for hiding this comment

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

HandlerMethodArgumentResolver 가 정확하게 하는게 뭔가요?

private final AuthService authService;
private final AuthorizationExtractor authorizationExtractor;

public LoginMemberArgumentResolver(AuthService authService, AuthorizationExtractor authorizationExtractor) {
this.authService = authService;
this.authorizationExtractor = authorizationExtractor;
}

@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 {
String token = authorizationExtractor.extract((HttpServletRequest) webRequest.getNativeRequest());
return authService.createAuthentication(token);
}
}
12 changes: 12 additions & 0 deletions src/main/java/roomescape/auth/SystemTimeProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package roomescape.auth;

import java.util.Date;
import org.springframework.stereotype.Component;

@Component
public class SystemTimeProvider implements TimeProvider {
@Override
public Date now() {
return new Date();
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/auth/TimeProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.auth;

import java.util.Date;

public interface TimeProvider {
Date now();
}
29 changes: 29 additions & 0 deletions src/main/java/roomescape/auth/WebConfig.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;

@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginMemberArgumentResolver loginMemberArgumentResolver;
private final AdminInterceptor adminInterceptor;

public WebConfig(LoginMemberArgumentResolver loginMemberArgumentResolver, AdminInterceptor adminInterceptor) {
this.loginMemberArgumentResolver = loginMemberArgumentResolver;
this.adminInterceptor = adminInterceptor;
}

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

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**");
}
}
18 changes: 11 additions & 7 deletions src/main/java/roomescape/reservation/ReservationController.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package roomescape.reservation;

import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.util.List;
import roomescape.auth.LoginMember;

@RestController
public class ReservationController {
Expand All @@ -26,12 +26,16 @@ public List<ReservationResponse> list() {
}

@PostMapping("/reservations")
public ResponseEntity create(@RequestBody ReservationRequest reservationRequest) {
if (reservationRequest.getName() == null
|| reservationRequest.getDate() == null
public ResponseEntity create(@RequestBody ReservationRequest reservationRequest, LoginMember loginMember) {
if (reservationRequest.getDate() == null

Choose a reason for hiding this comment

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

물론, 기존 코드에서 검증을 여기서 하지만
DTO 가 스스로가 메소드로 자신의 값들이 타당한지validXXX 와 같은 메소드를 가지는것도 좋을거 같은데 어떤가요??

Controller 가 DTO 의 특정 요소가 null 인걸 알 필요가 있을까? 라고 생각해서 입니다.

|| reservationRequest.getTheme() == null
|| reservationRequest.getTime() == null) {
return ResponseEntity.badRequest().build();
return ResponseEntity.badRequest().body("Invalid reservation request");
}

if (reservationRequest.getName() == null) {

Choose a reason for hiding this comment

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

이 부분도 위와 비슷하게 getName 이 null 인지 + 새로운 객체 생성을 DTO 내부로 은닉해도 될 거 같아요.

reservationRequest = new ReservationRequest(loginMember.name(), reservationRequest.getDate(),
reservationRequest.getTheme(), reservationRequest.getTime());
}
ReservationResponse reservation = reservationService.save(reservationRequest);

Expand Down
7 changes: 7 additions & 0 deletions src/main/java/roomescape/reservation/ReservationRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ public class ReservationRequest {
private Long theme;
private Long time;

public ReservationRequest(String name, String date, Long theme, Long time) {
this.name = name;
this.date = date;
this.theme = theme;
this.time = time;
}

public String getName() {
return name;
}
Expand Down
70 changes: 70 additions & 0 deletions src/test/java/roomescape/MissionStepTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import roomescape.reservation.ReservationResponse;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
Expand Down Expand Up @@ -43,4 +44,73 @@ public class MissionStepTest {

assertThat(checkResponse.body().jsonPath().getString("name")).isEqualTo("어드민");
}

@Test
void 이단계() {
String token = createToken("[email protected]", "password"); // 일단계에서 토큰을 추출하는 로직을 메서드로 따로 만들어서 활용하세요.

Map<String, String> params = new HashMap<>();
params.put("date", "2024-03-01");
params.put("time", "1");
params.put("theme", "1");

ExtractableResponse<Response> response = RestAssured.given().log().all()
.body(params)
.cookie("token", token)
.contentType(ContentType.JSON)
.post("/reservations")
.then().log().all()
.extract();

assertThat(response.statusCode()).isEqualTo(201);
assertThat(response.as(ReservationResponse.class).getName()).isEqualTo("어드민");

params.put("name", "브라운");

ExtractableResponse<Response> adminResponse = RestAssured.given().log().all()
.body(params)
.cookie("token", token)
.contentType(ContentType.JSON)
.post("/reservations")
.then().log().all()
.extract();

assertThat(adminResponse.statusCode()).isEqualTo(201);
assertThat(adminResponse.as(ReservationResponse.class).getName()).isEqualTo("브라운");
}

private String createToken(String email, String password) {
Map<String, String> params = new HashMap<>();
params.put("email", email);
params.put("password", password);

ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.body(params)
.when().post("/login")
.then().log().all()
.statusCode(200)
.extract();

return response.headers().get("Set-Cookie").getValue().split(";")[0].split("=")[1];
}

@Test
void 삼단계() {
String brownToken = createToken("[email protected]", "password");

RestAssured.given().log().all()
.cookie("token", brownToken)
.get("/admin")
.then().log().all()
.statusCode(401);

String adminToken = createToken("[email protected]", "password");

RestAssured.given().log().all()
.cookie("token", adminToken)
.get("/admin")
.then().log().all()
.statusCode(200);
}
}
Loading