diff --git a/build.gradle b/build.gradle index 8d52aebc6..81dffc709 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { testImplementation 'io.rest-assured:rest-assured:5.3.1' runtimeOnly 'com.h2database:h2' + + implementation 'org.projectlombok:lombok' } test { diff --git a/src/main/java/roomescape/auth/AdminInterceptor.java b/src/main/java/roomescape/auth/AdminInterceptor.java new file mode 100644 index 000000000..0f10e0cb8 --- /dev/null +++ b/src/main/java/roomescape/auth/AdminInterceptor.java @@ -0,0 +1,56 @@ +package roomescape.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; +import roomescape.jwt.JwtProvider; +import roomescape.member.Member; +import roomescape.member.MemberDao; + +public class AdminInterceptor implements HandlerInterceptor { + + private final JwtProvider jwtProvider; + private final MemberDao memberDao; + + public AdminInterceptor(JwtProvider jwtProvider, MemberDao memberDao) { + this.jwtProvider = jwtProvider; + this.memberDao = memberDao; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + String token = extractTokenFromCookies(cookies); + if (token == null || token.isEmpty() || !jwtProvider.isValidToken(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + String email = jwtProvider.extractEmail(token); + Member member = memberDao.findByEmailAndPassword(email, null); // 비밀번호 검증은 생략 + + + if (member == null || !"ADMIN".equals(member.getRole())) { //관리자 권한 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + return true; + } + + private String extractTokenFromCookies(Cookie[] cookies) { + for (Cookie cookie : cookies) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/roomescape/auth/LoginMember.java b/src/main/java/roomescape/auth/LoginMember.java new file mode 100644 index 000000000..ab48fc9ad --- /dev/null +++ b/src/main/java/roomescape/auth/LoginMember.java @@ -0,0 +1,19 @@ +package roomescape.auth; + +import lombok.Getter; + +@Getter +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; + } +} diff --git a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java b/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..8a2ea11f7 --- /dev/null +++ b/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,61 @@ +package roomescape.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +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.jwt.JwtProvider; +import roomescape.member.Member; +import roomescape.member.MemberDao; + +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberDao memberDao; + private final JwtProvider jwtProvider; + + public LoginMemberArgumentResolver(MemberDao memberDao, JwtProvider jwtProvider) { + this.memberDao = memberDao; + this.jwtProvider = jwtProvider; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Cookie[] cookies = request.getCookies(); + String token = extractTokenFromCookies(cookies); + + if (token == null || token.isEmpty()) { + throw new IllegalArgumentException("Token not found in cookies"); + } + + if (jwtProvider.isValidToken(token)) { + String email = jwtProvider.extractEmail(token); // 이메일 추출 + Member member = memberDao.findByEmailAndPassword(email, null); // 비밀번호는 검증 단계에서 사용하지 않음 + + if (member == null) { + throw new IllegalArgumentException("Member not found for email: " + email); + } + + return new LoginMember(member.getId(), member.getName(), member.getEmail(), member.getRole()); + } + throw new IllegalArgumentException("Invalid token"); + } + + private String extractTokenFromCookies(Cookie[] cookies) { + for (Cookie cookie : cookies) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return ""; + } +} \ No newline at end of file diff --git a/src/main/java/roomescape/config/WebConfig.java b/src/main/java/roomescape/config/WebConfig.java new file mode 100644 index 000000000..28de3a9cb --- /dev/null +++ b/src/main/java/roomescape/config/WebConfig.java @@ -0,0 +1,35 @@ +package roomescape.config; + +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.auth.AdminInterceptor; +import roomescape.auth.LoginMemberArgumentResolver; +import roomescape.jwt.JwtProvider; +import roomescape.member.MemberDao; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final JwtProvider jwtProvider; + private final MemberDao memberDao; + + public WebConfig(JwtProvider jwtProvider, MemberDao memberDao) { + this.jwtProvider = jwtProvider; + this.memberDao = memberDao; + } + + @Override //3단계 + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginMemberArgumentResolver(memberDao, jwtProvider)); + } + + @Override //3단계 + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new AdminInterceptor(jwtProvider, memberDao)) + .addPathPatterns("/admin/**"); + } +} diff --git a/src/main/java/roomescape/jwt/JwtProvider.java b/src/main/java/roomescape/jwt/JwtProvider.java new file mode 100644 index 000000000..5100f01c4 --- /dev/null +++ b/src/main/java/roomescape/jwt/JwtProvider.java @@ -0,0 +1,12 @@ +package roomescape.jwt; + +import roomescape.member.Member; + +public interface JwtProvider { + + String generateToken(Member member); + boolean isValidToken(String token); +// Long extractSubject(String token); + String extractEmail(String token); + +} diff --git a/src/main/java/roomescape/jwt/JwtProviderImpl.java b/src/main/java/roomescape/jwt/JwtProviderImpl.java new file mode 100644 index 000000000..5395661e3 --- /dev/null +++ b/src/main/java/roomescape/jwt/JwtProviderImpl.java @@ -0,0 +1,60 @@ +package roomescape.jwt; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; +import roomescape.member.Member; + +import java.nio.charset.StandardCharsets; + +@Component +public class JwtProviderImpl implements JwtProvider{ + + private static final String SECRET_KEY="Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E="; + private static final long EXPRIATION_TIME = 86400000; // 1일 + + @Override + public String generateToken(Member member) { + return Jwts.builder() + .setSubject(member.getId().toString()) + .claim("name", member.getName()) + .claim("email", member.getEmail()) + .claim("role", member.getRole()) + .signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8))) + .compact(); + } + + @Override + public boolean isValidToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token); + return true; + } catch (Exception e){ + return false; + } + } + +// @Override +// public Long extractSubject(String token) { +// Long memberId = Long.valueOf(Jwts.parserBuilder() +// .setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes())) +// .build() +// .parseClaimsJws(token) +// .getBody().getSubject()); +// return memberId; +// } + + @Override + public String extractEmail(String token) { + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8))) + .build() + .parseClaimsJws(token) + .getBody() + .get("email", String.class); + } + +} diff --git a/src/main/java/roomescape/login/LoginController.java b/src/main/java/roomescape/login/LoginController.java new file mode 100644 index 000000000..512193d37 --- /dev/null +++ b/src/main/java/roomescape/login/LoginController.java @@ -0,0 +1,53 @@ +package roomescape.login; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.member.MemberResponse; + +@RestController +public class LoginController { + + private final LoginService loginService; + + public LoginController(LoginService loginService) { + this.loginService = loginService; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response){ + + String token = loginService.login(loginRequest.getEmail(), loginRequest.getPassword()); + //쿠키 생성 + Cookie cookie = new Cookie("token", token); + cookie.setHttpOnly(true); + cookie.setPath("/"); + response.addCookie(cookie); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/login/check") //쿠키 조회 + public ResponseEntity checkLogin(HttpServletRequest request) { + + Cookie[] cookies = request.getCookies(); + String token = extractTokenFromCookies(cookies); + MemberResponse memberResponse = loginService.validateToken(token); + + return ResponseEntity.ok(memberResponse); + } + + private String extractTokenFromCookies(Cookie[] cookies) { + for (Cookie cookie: cookies){ + if ("token".equals(cookie.getName())){ + return cookie.getValue(); + } + } + return ""; + } +} diff --git a/src/main/java/roomescape/login/LoginRequest.java b/src/main/java/roomescape/login/LoginRequest.java new file mode 100644 index 000000000..c9ffb9cbe --- /dev/null +++ b/src/main/java/roomescape/login/LoginRequest.java @@ -0,0 +1,14 @@ +package roomescape.login; + +public class LoginRequest { + private String email; + private String password; + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } +} diff --git a/src/main/java/roomescape/login/LoginService.java b/src/main/java/roomescape/login/LoginService.java new file mode 100644 index 000000000..2f458ec1f --- /dev/null +++ b/src/main/java/roomescape/login/LoginService.java @@ -0,0 +1,9 @@ +package roomescape.login; + +import roomescape.member.MemberResponse; + +public interface LoginService { + + String login(String email, String password); + MemberResponse validateToken(String token); +} diff --git a/src/main/java/roomescape/login/LoginServiceImpl.java b/src/main/java/roomescape/login/LoginServiceImpl.java new file mode 100644 index 000000000..c5b8237c5 --- /dev/null +++ b/src/main/java/roomescape/login/LoginServiceImpl.java @@ -0,0 +1,42 @@ +package roomescape.login; + +import org.springframework.stereotype.Service; +import roomescape.jwt.JwtProvider; +import roomescape.member.Member; +import roomescape.member.MemberDao; +import roomescape.member.MemberResponse; + +@Service +public class LoginServiceImpl implements LoginService { + + private final MemberDao memberDao; + private final JwtProvider jwtProvider; + + public LoginServiceImpl(MemberDao memberDao, JwtProvider jwtProvider) { + this.memberDao = memberDao; + this.jwtProvider = jwtProvider; + } + + @Override + public String login(String email, String password) { + Member member = memberDao.findByEmailAndPassword(email, password); + return jwtProvider.generateToken(member); + } + + @Override + public MemberResponse validateToken(String token) { + + if (!jwtProvider.isValidToken(token)) { + throw new IllegalArgumentException("Invalid token"); + } + + String email = jwtProvider.extractEmail(token); + Member member = memberDao.findByEmailAndPassword(email, null); // 비밀번호는 로그인에서만 검증 + + if (member == null) { + throw new IllegalArgumentException("Member not found for email: " + email); + } + + return new MemberResponse(member.getId(), member.getName(), member.getEmail()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a0f33bbab..612d9890e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,4 +8,4 @@ spring.datasource.url=jdbc:h2:mem:database #spring.jpa.ddl-auto=create-drop #spring.jpa.defer-datasource-initialization=true -#roomescape.auth.jwt.secret= Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E= \ No newline at end of file +roomescape.auth.jwt.secret= Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E= \ No newline at end of file diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index 6add784bd..e8c9b53e6 100644 --- a/src/test/java/roomescape/MissionStepTest.java +++ b/src/test/java/roomescape/MissionStepTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; +import roomescape.reservation.ReservationResponse; import java.util.HashMap; import java.util.Map; @@ -35,4 +36,74 @@ public class MissionStepTest { assertThat(token).isNotBlank(); } + + private String createToken(String email, String password) { + Map params = new HashMap<>(); + params.put("email", email); + params.put("password", password); + + ExtractableResponse 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 token = createToken("admin@email.com", "password"); // 일단계에서 토큰을 추출하는 로직을 메서드로 따로 만들어서 활용하세요. + + Map params = new HashMap<>(); + params.put("date", "2024-03-01"); + params.put("time", "1"); + params.put("theme", "1"); + + ExtractableResponse 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 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("브라운"); + } + + @Test + void 삼단계() { + String brownToken = createToken("brown@email.com", "password"); + + RestAssured.given().log().all() + .cookie("token", brownToken) + .get("/admin") + .then().log().all() + .statusCode(401); + + String adminToken = createToken("admin@email.com", "password"); + + RestAssured.given().log().all() + .cookie("token", adminToken) + .get("/admin") + .then().log().all() + .statusCode(200); + } + } \ No newline at end of file