diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..6a6de7313 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index c2065bc26..217351608 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.DS_Store +.application.properties ### STS ### .apt_generated diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 000000000..e15cf5ee1 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 000000000..7c2b567ee Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 000000000..63d3ba0c6 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/roomescape/auth/JwtProvider.java b/src/main/java/roomescape/auth/JwtProvider.java new file mode 100644 index 000000000..e2fb1ca6c --- /dev/null +++ b/src/main/java/roomescape/auth/JwtProvider.java @@ -0,0 +1,58 @@ +package roomescape.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class JwtProvider { + + @Value("${roomescape.auth.jwt.secret}") + private String SECRET_KEY; + + private static final long EXPIRATION_MS = 1000 * 60 * 60 * 24; + + public String generateToken(Long memberId, String email, String role) { + return Jwts.builder() + .setSubject(String.valueOf(memberId)) + .claim("email", email) + .claim("role", role) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS)) + .signWith(SignatureAlgorithm.HS256, SECRET_KEY) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(SECRET_KEY) + .parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + public String getRoleFromToken(String token) { + Claims claims = Jwts.parser() + .setSigningKey(SECRET_KEY) + .parseClaimsJws(token) + .getBody(); + + return claims.get("role", String.class); + } + + public String getEmailFromToken(String token) { + Claims claims = Jwts.parser() + .setSigningKey(SECRET_KEY) + .parseClaimsJws(token) + .getBody(); + + return claims.get("email", String.class); + } +} diff --git a/src/main/java/roomescape/config/WebConfig.java b/src/main/java/roomescape/config/WebConfig.java new file mode 100644 index 000000000..b9d2d56b5 --- /dev/null +++ b/src/main/java/roomescape/config/WebConfig.java @@ -0,0 +1,24 @@ +package roomescape.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import roomescape.interceptor.AdminInterceptor; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AdminInterceptor adminInterceptor; + + @Autowired + public WebConfig(AdminInterceptor adminInterceptor) { + this.adminInterceptor = adminInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor) + .addPathPatterns("/admin/**"); + } +} diff --git a/src/main/java/roomescape/controller/LoginController.java b/src/main/java/roomescape/controller/LoginController.java new file mode 100644 index 000000000..383b41e70 --- /dev/null +++ b/src/main/java/roomescape/controller/LoginController.java @@ -0,0 +1,47 @@ +package roomescape.controller; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import roomescape.auth.JwtProvider; +import roomescape.login.LoginRequest; +import roomescape.member.Member; +import roomescape.member.MemberDao; + +@RestController +public class LoginController { + + private final MemberDao memberDao; + private final JwtProvider jwtProvider; + + public LoginController(MemberDao memberDao, JwtProvider jwtProvider) { + this.memberDao = memberDao; + this.jwtProvider = jwtProvider; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { + try { + Member member = memberDao.findByEmailAndPassword( + loginRequest.getEmail(), + loginRequest.getPassword() + ); + + String token = jwtProvider.generateToken( + member.getId(), + member.getEmail(), + member.getRole() + ); + + Cookie cookie = new Cookie("token", token); + cookie.setHttpOnly(true); + cookie.setPath("/"); + response.addCookie(cookie); + + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.status(401).build(); + } + } +} diff --git a/src/main/java/roomescape/PageController.java b/src/main/java/roomescape/controller/PageController.java similarity index 96% rename from src/main/java/roomescape/PageController.java rename to src/main/java/roomescape/controller/PageController.java index ac8ef9408..5f3f8ef7e 100644 --- a/src/main/java/roomescape/PageController.java +++ b/src/main/java/roomescape/controller/PageController.java @@ -1,4 +1,4 @@ -package roomescape; +package roomescape.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/roomescape/interceptor/AdminInterceptor.java b/src/main/java/roomescape/interceptor/AdminInterceptor.java new file mode 100644 index 000000000..9bc6caf5f --- /dev/null +++ b/src/main/java/roomescape/interceptor/AdminInterceptor.java @@ -0,0 +1,41 @@ +package roomescape.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import roomescape.auth.JwtProvider; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + + private final JwtProvider jwtProvider; + + public AdminInterceptor(JwtProvider jwtProvider) { + this.jwtProvider = jwtProvider; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String token = extractTokenFromCookies(request); + + if (token == null || !jwtProvider.validateToken(token) || !"ADMIN".equals(jwtProvider.getRoleFromToken(token))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + return true; + } + + private String extractTokenFromCookies(HttpServletRequest request) { + if (request.getCookies() == null) return null; + + for (var cookie : request.getCookies()) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } +} diff --git a/src/main/java/roomescape/login/LoginMember.java b/src/main/java/roomescape/login/LoginMember.java new file mode 100644 index 000000000..528b835a7 --- /dev/null +++ b/src/main/java/roomescape/login/LoginMember.java @@ -0,0 +1,20 @@ +package roomescape.login; + +public class LoginMember { + private final Long id; + private final String name; + private final String email; + private final 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; } +} diff --git a/src/main/java/roomescape/login/LoginMemberArgumentResolver.java b/src/main/java/roomescape/login/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..913559d84 --- /dev/null +++ b/src/main/java/roomescape/login/LoginMemberArgumentResolver.java @@ -0,0 +1,65 @@ +package roomescape.login; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +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.member.Member; +import roomescape.member.MemberDao; + +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final String secretKey; + private final MemberDao memberDao; + + public LoginMemberArgumentResolver(String secretKey, MemberDao memberDao) { + this.secretKey = secretKey; + this.memberDao = memberDao; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return LoginMember.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + if (request == null || request.getCookies() == null) { + return null; + } + + String token = ""; + for (Cookie cookie : request.getCookies()) { + if ("token".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + if (token.isBlank()) { + return null; + } + + Claims claims = Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes())) + .build() + .parseClaimsJws(token) + .getBody(); + + Long memberId = Long.valueOf(claims.getSubject()); + Member member = memberDao.findById(memberId) + .orElseThrow(() -> new IllegalStateException("회원을 찾을 수 없습니다")); + + return new LoginMember(member.getId(), member.getName(), member.getEmail(), member.getRole()); + } +} 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/member/MemberDao.java b/src/main/java/roomescape/member/MemberDao.java index 81f77f4cd..0aa992301 100644 --- a/src/main/java/roomescape/member/MemberDao.java +++ b/src/main/java/roomescape/member/MemberDao.java @@ -5,6 +5,9 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public class MemberDao { private JdbcTemplate jdbcTemplate; @@ -12,6 +15,34 @@ public class MemberDao { public MemberDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } + public Optional findByEmail(String email) { + List results = jdbcTemplate.query( + "SELECT id, name, email, role FROM member WHERE email = ?", + (rs, rowNum) -> new Member( + rs.getLong("id"), + rs.getString("name"), + rs.getString("email"), + rs.getString("role") + ), + email + ); + return results.stream().findFirst(); + } + + + public Optional findById(Long id) { + List results = jdbcTemplate.query( + "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 + ); + return results.stream().findFirst(); + } public Member save(Member member) { KeyHolder keyHolder = new GeneratedKeyHolder(); diff --git a/src/main/java/roomescape/member/MemberService.java b/src/main/java/roomescape/member/MemberService.java index ccaa8cba5..2e6ae1c9a 100644 --- a/src/main/java/roomescape/member/MemberService.java +++ b/src/main/java/roomescape/member/MemberService.java @@ -1,17 +1,28 @@ package roomescape.member; import org.springframework.stereotype.Service; +import roomescape.auth.JwtProvider; @Service public class MemberService { - private MemberDao memberDao; + private final MemberDao memberDao; + private final JwtProvider jwtProvider; - public MemberService(MemberDao memberDao) { + public MemberService(MemberDao memberDao, JwtProvider jwtProvider) { this.memberDao = memberDao; + this.jwtProvider = jwtProvider; } 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 findByToken(String token) { + String email = jwtProvider.getEmailFromToken(token); + if (email == null) { + return null; + } + return memberDao.findByEmail(email).orElse(null); + } } diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index 6add784bd..ed2d90852 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; @@ -17,22 +18,39 @@ @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) public class MissionStepTest { - @Test - void 일단계() { - Map params = new HashMap<>(); - params.put("email", "admin@email.com"); - params.put("password", "password"); + private String createToken(String email, String password) { + Map loginParams = new HashMap<>(); + loginParams.put("email", email); + loginParams.put("password", password); - ExtractableResponse response = RestAssured.given().log().all() + ExtractableResponse loginResponse = RestAssured.given() + .log().all() .contentType(ContentType.JSON) - .body(params) - .when().post("/login") + .body(loginParams) + .post("/login") .then().log().all() .statusCode(200) .extract(); - String token = response.headers().get("Set-Cookie").getValue().split(";")[0].split("=")[1]; + String setCookie = loginResponse.header("Set-Cookie"); + return setCookie.split(";")[0].split("=")[1]; + } + @Test + void 삼단계() { + String brownToken = createToken("brown@email.com", "password"); - assertThat(token).isNotBlank(); + 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 +}