diff --git a/README.md b/README.md new file mode 100644 index 000000000..afb14849e --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# 방탈출 예약 관리 + +이 저장소는 Spring MVC와 JPA를 공부하며 만든 방탈출 예약 서비스입니다. JDBC와 직접 작성한 DAO로 시작해 Spring Data JPA, 로그인 인증, 예약 대기 기능을 차례로 붙였습니다. 학습 단계마다 코드를 고쳐 온 흔적이 남아 있어 기능이 늘면서 구조가 어떻게 달라졌는지 살펴볼 수 있습니다. + +## 주요 기능 + +- 회원가입과 로그인 +- Access Token과 Refresh Token을 사용한 인증 +- 관리자 전용 예약, 테마, 시간 관리 +- 예약 생성과 멱등성 키를 이용한 중복 요청 방지 +- 내 예약 목록 조회 +- 예약 대기 등록과 대기 순번 조회 +- 테마와 시간의 소프트 삭제 +- 프로파일에 따른 초기 데이터 등록 + +## 기술 스택 + +- Java 17 +- Spring Boot 3.1 +- Spring MVC +- Spring Data JPA +- H2 Database +- JWT +- Thymeleaf +- Gradle +- RestAssured, JUnit 5, AssertJ + +## 프로젝트 구조 + +```text +src/main/java +├── jwt JWT 생성과 파싱 +└── roomescape + ├── auth 인증, 인가, 쿠키 처리 + ├── exception 예외와 오류 코드 + ├── loader 실행 환경별 초기 데이터 + ├── member 회원 + ├── reservation 예약 + ├── theme 테마 + ├── time 예약 시간 + └── waiting 예약 대기 +``` + +패키지는 기능 단위로 나눴습니다. 컨트롤러는 HTTP 요청과 응답을 받고 서비스는 유스케이스와 트랜잭션을 처리합니다. 데이터 접근은 `JpaRepository`를 상속한 저장소가 맡습니다. + +## 실행 방법 + +Java 17이 필요합니다. 데이터베이스를 따로 설치하지 않아도 H2 인메모리 데이터베이스로 실행할 수 있습니다. + +Windows: + +```bash +gradlew.bat bootRun +``` + +macOS 또는 Linux: + +```bash +./gradlew bootRun +``` + +실행 후 [http://localhost:8080](http://localhost:8080)으로 접속하면 됩니다. H2 콘솔 주소는 [http://localhost:8080/h2-console](http://localhost:8080/h2-console)입니다. + +기본 계정은 다음과 같습니다. + +| 구분 | 이메일 | 비밀번호 | +| --- | --- | --- | +| 관리자 | `admin@email.com` | `password` | +| 사용자 | `brown@email.com` | `password` | + +## 초기 데이터 + +초기 데이터는 `schema.sql`이 아니라 `CommandLineRunner`로 등록합니다. + +- 기본 환경에서는 `DataLoader`가 회원 정보를 등록합니다. +- `test` 프로파일에서는 `TestDataLoader`가 회원, 테마, 시간, 예약 데이터를 등록합니다. + +테이블은 JPA 엔티티를 보고 Hibernate가 생성합니다. 현재 H2 인메모리 데이터베이스와 `create-drop` 설정을 사용하므로 애플리케이션을 다시 실행하면 데이터도 초기화됩니다. + +## API + +### 회원과 인증 + +| Method | URI | 설명 | +| --- | --- | --- | +| `POST` | `/members` | 회원가입 | +| `POST` | `/login` | 로그인 | +| `POST` | `/token/refresh` | Access Token 재발급 | +| `GET` | `/login/check` | 로그인 상태 확인 | +| `POST` | `/logout` | 로그아웃 | + +### 예약과 대기 + +| Method | URI | 설명 | +| --- | --- | --- | +| `GET` | `/reservations` | 전체 예약 조회 | +| `POST` | `/reservations` | 예약 생성 | +| `DELETE` | `/reservations/{id}` | 예약 삭제 | +| `GET` | `/reservations-mine` | 내 예약과 예약 대기 조회 | +| `POST` | `/waitings` | 예약 대기 등록 | + +### 테마와 시간 + +| Method | URI | 설명 | +| --- | --- | --- | +| `GET` | `/themes` | 테마 목록 조회 | +| `POST` | `/themes` | 테마 생성 | +| `DELETE` | `/themes/{id}` | 테마 삭제 | +| `GET` | `/times` | 시간 목록 조회 | +| `POST` | `/times` | 시간 생성 | +| `DELETE` | `/times/{id}` | 시간 삭제 | +| `GET` | `/available-times` | 날짜와 테마별 예약 가능 시간 조회 | + +예약, 테마, 시간 관리 API 일부는 관리자 권한이 필요합니다. + +## 구현하면서 바꾼 점 + +### Spring Data JPA + +직접 `EntityManager`를 다루던 DAO를 `JpaRepository`로 바꿨습니다. 단순 조회에는 파생 쿼리를 씁니다. 여러 엔티티를 조인해 DTO로 반환하는 예약 조회에는 JPQL 생성자 표현식을 사용했습니다. + +### 소프트 삭제 + +테마와 시간은 행을 바로 지우지 않고 `deleted` 상태만 바꿉니다. 예약을 생성할 때는 삭제되지 않은 테마와 시간만 조회합니다. + +### 예약 대기 순번 + +같은 날짜, 테마, 시간에 먼저 등록된 대기 건수를 JPQL 서브쿼리로 계산합니다. 조회된 건수에 1을 더해 `1번째 예약대기`처럼 응답합니다. + +### JWT + +JWT 라이브러리를 직접 다루는 코드는 `roomescape` 패키지 밖의 `JwtUtils`에 모았습니다. 이 클래스에는 컴포넌트 어노테이션을 붙이지 않고 `JwtConfig`에서 빈으로 등록했습니다. 토큰에서 회원 식별 정보와 권한을 읽기 때문에 토큰 확인 과정에서는 회원 데이터베이스를 다시 조회하지 않습니다. + +## 테스트 + +Windows: + +```bash +gradlew.bat test +``` + +macOS 또는 Linux: + +```bash +./gradlew test +``` + +테스트는 `test` 프로파일로 실행됩니다. 로그인, 예약 생성, 멱등성 키, 예약 대기 순번, JWT 설정을 REST API 기준으로 확인합니다. + diff --git a/build.gradle b/build.gradle index 8d52aebc6..0a3e8c31a 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0' diff --git a/src/main/java/jwt/JwtUtils.java b/src/main/java/jwt/JwtUtils.java new file mode 100644 index 000000000..2e2da4464 --- /dev/null +++ b/src/main/java/jwt/JwtUtils.java @@ -0,0 +1,40 @@ +package jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.Map; + +public class JwtUtils { + private final Key signingKey; + + public JwtUtils(String secretKey) { + this.signingKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + public String createToken(String subject, Map claims, long expirationTime) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .setSubject(subject) + .addClaims(claims) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/roomescape/AuthenticationException.java b/src/main/java/roomescape/AuthenticationException.java deleted file mode 100644 index 0b8c3ac3f..000000000 --- a/src/main/java/roomescape/AuthenticationException.java +++ /dev/null @@ -1,4 +0,0 @@ -package roomescape; - -public class AuthenticationException extends RuntimeException { -} diff --git a/src/main/java/roomescape/ExceptionController.java b/src/main/java/roomescape/ExceptionController.java index 1fca08893..58ef61822 100644 --- a/src/main/java/roomescape/ExceptionController.java +++ b/src/main/java/roomescape/ExceptionController.java @@ -1,24 +1,68 @@ package roomescape; +import lombok.Getter; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import roomescape.exception.AuthenticationException; +import roomescape.exception.AuthorizationException; +import roomescape.exception.ConflictException; +import roomescape.exception.ErrorCode; +import roomescape.exception.NotFoundException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -@ControllerAdvice +@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ public class ExceptionController { + private static final Logger log = LoggerFactory.getLogger(ExceptionController.class); + @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthenticationException(AuthenticationException e) { - return ResponseEntity.status(401) - .body(new ErrorResponse(401, "로그인이 필요합니다.")); + return error(e.getErrorCode()); + } + + @ExceptionHandler(AuthorizationException.class) + public ResponseEntity handleAuthorizationException(AuthorizationException e) { + return error(e.getErrorCode()); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException(NotFoundException e) { + log.warn("Resource not found: {}", e.getMessage()); + return error(e.getErrorCode()); + } + + @ExceptionHandler({ConflictException.class, DataIntegrityViolationException.class}) + public ResponseEntity handleConflictException(Exception e) { + if (e instanceof ConflictException conflictException) { + return error(conflictException.getErrorCode()); + } + return error(ErrorCode.CONFLICT_REQUEST); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return error(HttpStatus.BAD_REQUEST, e.getMessage()); } @ExceptionHandler(Exception.class) - public ResponseEntity handleRuntimeException(Exception e) { + public ResponseEntity handleException(Exception e) { e.printStackTrace(); - return ResponseEntity.badRequest() - .body(new ErrorResponse(400, "잘못된 요청입니다.")); + return error(ErrorCode.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity error(ErrorCode errorCode) { + return error(errorCode.getStatus(), errorCode.getMessage()); + } + + private ResponseEntity error(HttpStatus status, String message) { + return ResponseEntity.status(status) + .body(new ErrorResponse(status.value(), message)); } + @Getter public static class ErrorResponse { private final int statusCode; private final String message; @@ -27,13 +71,5 @@ public ErrorResponse(int statusCode, String message) { this.statusCode = statusCode; this.message = message; } - - public int getStatusCode() { - return statusCode; - } - - public String getMessage() { - return message; - } } } diff --git a/src/main/java/roomescape/PageController.java b/src/main/java/roomescape/PageController.java index ac8ef9408..7cbb269f4 100644 --- a/src/main/java/roomescape/PageController.java +++ b/src/main/java/roomescape/PageController.java @@ -2,25 +2,30 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import roomescape.auth.AdminOnly; @Controller public class PageController { @GetMapping("/admin") + @AdminOnly public String admin() { return "admin/index"; } @GetMapping("/admin/reservation") + @AdminOnly public String adminReservation() { return "admin/reservation"; } @GetMapping("/admin/theme") + @AdminOnly public String adminTheme() { return "admin/theme"; } @GetMapping("/admin/time") + @AdminOnly public String adminTime() { return "admin/time"; } diff --git a/src/main/java/roomescape/WebConfig.java b/src/main/java/roomescape/WebConfig.java index 108ed4555..ab363560b 100644 --- a/src/main/java/roomescape/WebConfig.java +++ b/src/main/java/roomescape/WebConfig.java @@ -2,22 +2,30 @@ 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.LoginMemberArgumentResolver; +import roomescape.auth.AdminInterceptor; +import roomescape.auth.AuthUserArgumentResolver; import java.util.List; -@Configuration +@Configuration // Spring MVC 설정을 추가함 public class WebConfig implements WebMvcConfigurer { - private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AuthUserArgumentResolver authUserArgumentResolver; + private final AdminInterceptor adminInterceptor; - public WebConfig(LoginMemberArgumentResolver loginMemberArgumentResolver) { - this.loginMemberArgumentResolver = loginMemberArgumentResolver; + public WebConfig(AuthUserArgumentResolver authUserArgumentResolver, AdminInterceptor adminInterceptor) { + this.authUserArgumentResolver = authUserArgumentResolver; + this.adminInterceptor = adminInterceptor; } @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(loginMemberArgumentResolver); - // 로그인에 이 resolver를 사용해라 + resolvers.add(authUserArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor); } } diff --git a/src/main/java/roomescape/auth/AdminInterceptor.java b/src/main/java/roomescape/auth/AdminInterceptor.java new file mode 100644 index 000000000..9d3360f6d --- /dev/null +++ b/src/main/java/roomescape/auth/AdminInterceptor.java @@ -0,0 +1,39 @@ +package roomescape.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import roomescape.exception.AuthorizationException; +import roomescape.member.MemberRole; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + private final AuthCookieProvider authCookieProvider; + private final AuthTokenService authTokenService; + + public AdminInterceptor(AuthCookieProvider authCookieProvider, AuthTokenService authTokenService) { + this.authCookieProvider = authCookieProvider; + this.authTokenService = authTokenService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod) || !hasAdminOnly(handlerMethod)) { + return true; + } + + String token = authCookieProvider.extractAccessToken(request); + LoginMemberInfo loginMember = authTokenService.parseAccessToken(token); + if (MemberRole.ADMIN == loginMember.role()) { + return true; + } + throw new AuthorizationException(); + } + + private boolean hasAdminOnly(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(AdminOnly.class) + || handlerMethod.getBeanType().isAnnotationPresent(AdminOnly.class); + } +} diff --git a/src/main/java/roomescape/auth/AdminOnly.java b/src/main/java/roomescape/auth/AdminOnly.java new file mode 100644 index 000000000..b70177472 --- /dev/null +++ b/src/main/java/roomescape/auth/AdminOnly.java @@ -0,0 +1,11 @@ +package roomescape.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminOnly { +} diff --git a/src/main/java/roomescape/auth/AuthCookieProvider.java b/src/main/java/roomescape/auth/AuthCookieProvider.java index a69541a7e..053b7c541 100644 --- a/src/main/java/roomescape/auth/AuthCookieProvider.java +++ b/src/main/java/roomescape/auth/AuthCookieProvider.java @@ -3,39 +3,82 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; -import roomescape.AuthenticationException; +import roomescape.exception.AuthenticationException; @Component public class AuthCookieProvider { - private static final String TOKEN_COOKIE_NAME = "token"; + private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + private static final String LEGACY_TOKEN_COOKIE_NAME = "token"; private static final String COOKIE_PATH = "/"; public Cookie createLoginCookie(String token) { - Cookie cookie = new Cookie(TOKEN_COOKIE_NAME, token); + return createAccessTokenCookie(token); + } + + public Cookie createAccessTokenCookie(String token) { + return createCookie(ACCESS_TOKEN_COOKIE_NAME, token); + } + + public Cookie createRefreshTokenCookie(String token) { + return createCookie(REFRESH_TOKEN_COOKIE_NAME, token); + } + + private Cookie createCookie(String name, String value) { + Cookie cookie = new Cookie(name, value); cookie.setHttpOnly(true); cookie.setPath(COOKIE_PATH); return cookie; } public Cookie createLogoutCookie() { - Cookie cookie = new Cookie(TOKEN_COOKIE_NAME, ""); + return createLogoutCookie(LEGACY_TOKEN_COOKIE_NAME); + } + + public Cookie createLogoutAccessTokenCookie() { + return createLogoutCookie(ACCESS_TOKEN_COOKIE_NAME); + } + + public Cookie createLogoutRefreshTokenCookie() { + return createLogoutCookie(REFRESH_TOKEN_COOKIE_NAME); + } + + private Cookie createLogoutCookie(String name) { + Cookie cookie = new Cookie(name, ""); cookie.setHttpOnly(true); cookie.setPath(COOKIE_PATH); cookie.setMaxAge(0); return cookie; } - public String extractToken(HttpServletRequest request) { + public String extractAccessToken(HttpServletRequest request) { + return extractToken(request, ACCESS_TOKEN_COOKIE_NAME, LEGACY_TOKEN_COOKIE_NAME); + } + + public String extractRefreshToken(HttpServletRequest request) { + return extractToken(request, REFRESH_TOKEN_COOKIE_NAME); + } + + private String extractToken(HttpServletRequest request, String... cookieNames) { Cookie[] cookies = request.getCookies(); if (cookies == null) { throw new AuthenticationException(); } for (Cookie cookie : cookies) { - if (TOKEN_COOKIE_NAME.equals(cookie.getName())) { + if (contains(cookieNames, cookie.getName())) { return cookie.getValue(); } } throw new AuthenticationException(); } + + private boolean contains(String[] values, String target) { + for (String value : values) { + if (value.equals(target)) { + return true; + } + } + return false; + } } diff --git a/src/main/java/roomescape/auth/AuthTokenService.java b/src/main/java/roomescape/auth/AuthTokenService.java new file mode 100644 index 000000000..edee0846f --- /dev/null +++ b/src/main/java/roomescape/auth/AuthTokenService.java @@ -0,0 +1,74 @@ +package roomescape.auth; + +import io.jsonwebtoken.Claims; +import jwt.JwtUtils; +import org.springframework.stereotype.Service; +import roomescape.exception.AuthenticationException; +import roomescape.member.Member; +import roomescape.member.MemberRole; + +import java.util.Map; + +@Service +public class AuthTokenService { + private static final long ACCESS_TOKEN_EXPIRATION_TIME = 1000L * 60 * 30; + private static final long REFRESH_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; + + private final JwtUtils jwtUtils; + + public AuthTokenService(JwtUtils jwtUtils) { + this.jwtUtils = jwtUtils; + } + + public LoginTokens createLoginTokens(Member member) { + LoginMemberInfo loginMember = toLoginMemberInfo(member); + return new LoginTokens( + createToken(loginMember, TokenType.ACCESS, ACCESS_TOKEN_EXPIRATION_TIME), + createToken(loginMember, TokenType.REFRESH, REFRESH_TOKEN_EXPIRATION_TIME) + ); + } + + public String createAccessToken(Member member) { + return createToken(toLoginMemberInfo(member), TokenType.ACCESS, ACCESS_TOKEN_EXPIRATION_TIME); + } + + public LoginMemberInfo parseAccessToken(String token) { + return parseMember(token, TokenType.ACCESS); + } + + public LoginMemberInfo parseRefreshToken(String token) { + return parseMember(token, TokenType.REFRESH); + } + + private LoginMemberInfo toLoginMemberInfo(Member member) { + return new LoginMemberInfo(member.id(), member.name(), member.email(), member.role()); + } + + private String createToken(LoginMemberInfo member, TokenType tokenType, long expirationTime) { + Map claims = Map.of( + "id", member.id(), + "name", member.name(), + "role", member.role().name(), + "type", tokenType.name() + ); + return jwtUtils.createToken(member.email(), claims, expirationTime); + } + + private LoginMemberInfo parseMember(String token, TokenType expectedTokenType) { + try { + Claims claims = jwtUtils.parseToken(token); + TokenType tokenType = TokenType.from(claims.get("type", String.class)); + if (tokenType != expectedTokenType) { + throw new IllegalArgumentException("Invalid token type"); + } + return new LoginMemberInfo( + claims.get("id", Number.class).longValue(), + claims.get("name", String.class), + claims.getSubject(), + MemberRole.from(claims.get("role", String.class)) + ); + } catch (Exception exception) { + throw new AuthenticationException(); + } + } +} diff --git a/src/main/java/roomescape/auth/LoginMember.java b/src/main/java/roomescape/auth/AuthUser.java similarity index 88% rename from src/main/java/roomescape/auth/LoginMember.java rename to src/main/java/roomescape/auth/AuthUser.java index 41773cd87..867a40bdf 100644 --- a/src/main/java/roomescape/auth/LoginMember.java +++ b/src/main/java/roomescape/auth/AuthUser.java @@ -7,5 +7,5 @@ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) -public @interface LoginMember { +public @interface AuthUser { } diff --git a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java b/src/main/java/roomescape/auth/AuthUserArgumentResolver.java similarity index 79% rename from src/main/java/roomescape/auth/LoginMemberArgumentResolver.java rename to src/main/java/roomescape/auth/AuthUserArgumentResolver.java index 30f1eff91..31c80dbe4 100644 --- a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java +++ b/src/main/java/roomescape/auth/AuthUserArgumentResolver.java @@ -7,26 +7,25 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import roomescape.AuthenticationException; -import roomescape.member.MemberService; +import roomescape.exception.AuthenticationException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Optional; @Component -public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { - private final MemberService memberService; +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + private final AuthTokenService authTokenService; private final AuthCookieProvider authCookieProvider; - public LoginMemberArgumentResolver(MemberService memberService, AuthCookieProvider authCookieProvider) { - this.memberService = memberService; + public AuthUserArgumentResolver(AuthTokenService authTokenService, AuthCookieProvider authCookieProvider) { + this.authTokenService = authTokenService; this.authCookieProvider = authCookieProvider; } @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(LoginMember.class) + return parameter.hasParameterAnnotation(AuthUser.class) && (LoginMemberInfo.class.isAssignableFrom(parameter.getParameterType()) || isOptionalLoginMemberInfo(parameter)); } @@ -46,13 +45,13 @@ public Object resolveArgument( } try { - String token = authCookieProvider.extractToken(request); - LoginMemberInfo loginMember = memberService.checkLogin(token); + String token = authCookieProvider.extractAccessToken(request); + LoginMemberInfo loginMember = authTokenService.parseAccessToken(token); if (optional) { return Optional.of(loginMember); } return loginMember; - } catch (RuntimeException e) { + } catch (AuthenticationException e) { return handleAuthenticationFailure(optional); } } diff --git a/src/main/java/roomescape/auth/JwtConfig.java b/src/main/java/roomescape/auth/JwtConfig.java new file mode 100644 index 000000000..d9f688810 --- /dev/null +++ b/src/main/java/roomescape/auth/JwtConfig.java @@ -0,0 +1,15 @@ +package roomescape.auth; + +import jwt.JwtUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfig { + + @Bean + public JwtUtils jwtUtils(@Value("${roomescape.auth.jwt.secret}") String secretKey) { + return new JwtUtils(secretKey); + } +} diff --git a/src/main/java/roomescape/auth/JwtTokenProvider.java b/src/main/java/roomescape/auth/JwtTokenProvider.java deleted file mode 100644 index bddf3569e..000000000 --- a/src/main/java/roomescape/auth/JwtTokenProvider.java +++ /dev/null @@ -1,74 +0,0 @@ -package roomescape.auth; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; -import org.springframework.stereotype.Component; -import roomescape.member.Member; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Date; -import java.util.Map; - -@Component -public class JwtTokenProvider { - private static final String SECRET_KEY = "roomescape-secret-key-for-jwt-token"; - private static final long EXPIRATION_TIME = 1000 * 60 * 60; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - public String createToken(Member member) { - Date now = new Date(); - Date expiration = new Date(now.getTime() + EXPIRATION_TIME); - - return Jwts.builder() - .setSubject(member.getEmail()) - .claim("id", member.getId()) - .claim("name", member.getName()) - .claim("role", member.getRole()) - .setIssuedAt(now) - .setExpiration(expiration) - .signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) - .compact(); - } - - public LoginMemberInfo parseMember(String token) { - try { - Map claims = parseClaims(token); - Long id = Long.valueOf(claims.get("id").toString()); - String name = claims.get("name").toString(); - String email = claims.get("sub").toString(); - return new LoginMemberInfo(id, name, email); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid token"); - } - } - - private Map parseClaims(String token) throws Exception { - String[] parts = token.split("\\."); - if (parts.length != 3 || !isValidSignature(parts)) { - throw new IllegalArgumentException("Invalid token"); - } - - String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); - Map claims = OBJECT_MAPPER.readValue(payload, new TypeReference<>() { - }); - Number expiration = (Number) claims.get("exp"); - if (expiration.longValue() < System.currentTimeMillis() / 1000) { - throw new IllegalArgumentException("Expired token"); - } - return claims; - } - - private boolean isValidSignature(String[] parts) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - byte[] signature = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)); - String encodedSignature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); - return encodedSignature.equals(parts[2]); - } -} diff --git a/src/main/java/roomescape/auth/LoginMemberInfo.java b/src/main/java/roomescape/auth/LoginMemberInfo.java index fbcc8b8cc..736b8f18a 100644 --- a/src/main/java/roomescape/auth/LoginMemberInfo.java +++ b/src/main/java/roomescape/auth/LoginMemberInfo.java @@ -1,25 +1,6 @@ package roomescape.auth; -public class LoginMemberInfo { - private final Long id; - private final String name; - private final String email; +import roomescape.member.MemberRole; - public LoginMemberInfo(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 LoginMemberInfo(Long id, String name, String email, MemberRole role) { } diff --git a/src/main/java/roomescape/auth/LoginTokens.java b/src/main/java/roomescape/auth/LoginTokens.java new file mode 100644 index 000000000..203ddb633 --- /dev/null +++ b/src/main/java/roomescape/auth/LoginTokens.java @@ -0,0 +1,4 @@ +package roomescape.auth; + +public record LoginTokens(String accessToken, String refreshToken) { +} diff --git a/src/main/java/roomescape/auth/TokenType.java b/src/main/java/roomescape/auth/TokenType.java new file mode 100644 index 000000000..80d32ce79 --- /dev/null +++ b/src/main/java/roomescape/auth/TokenType.java @@ -0,0 +1,10 @@ +package roomescape.auth; + +public enum TokenType { + ACCESS, + REFRESH; + + public static TokenType from(String value) { + return TokenType.valueOf(value); + } +} diff --git a/src/main/java/roomescape/exception/AuthenticationException.java b/src/main/java/roomescape/exception/AuthenticationException.java new file mode 100644 index 000000000..4114d2ca6 --- /dev/null +++ b/src/main/java/roomescape/exception/AuthenticationException.java @@ -0,0 +1,18 @@ +package roomescape.exception; + +public class AuthenticationException extends RuntimeException { + private final ErrorCode errorCode; + + public AuthenticationException() { + this(ErrorCode.LOGIN_REQUIRED); + } + + public AuthenticationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/AuthorizationException.java b/src/main/java/roomescape/exception/AuthorizationException.java new file mode 100644 index 000000000..90798f20c --- /dev/null +++ b/src/main/java/roomescape/exception/AuthorizationException.java @@ -0,0 +1,18 @@ +package roomescape.exception; + +public class AuthorizationException extends RuntimeException { + private final ErrorCode errorCode; + + public AuthorizationException() { + this(ErrorCode.ADMIN_AUTHORIZATION_REQUIRED); + } + + public AuthorizationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/ConflictException.java b/src/main/java/roomescape/exception/ConflictException.java new file mode 100644 index 000000000..70f869a94 --- /dev/null +++ b/src/main/java/roomescape/exception/ConflictException.java @@ -0,0 +1,14 @@ +package roomescape.exception; + +public class ConflictException extends RuntimeException { + private final ErrorCode errorCode; + + public ConflictException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/ErrorCode.java b/src/main/java/roomescape/exception/ErrorCode.java new file mode 100644 index 000000000..5fc60727d --- /dev/null +++ b/src/main/java/roomescape/exception/ErrorCode.java @@ -0,0 +1,31 @@ +package roomescape.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + ADMIN_AUTHORIZATION_REQUIRED(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다."), + THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 테마입니다."), + TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 시간입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 예약입니다."), + IDEMPOTENCY_KEY_CONFLICT(HttpStatus.CONFLICT, "동일한 Idempotency-Key로 다른 요청을 처리할 수 없습니다."), + CONFLICT_REQUEST(HttpStatus.CONFLICT, "이미 존재하거나 현재 상태와 충돌하는 요청입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String message; + + ErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/roomescape/exception/NotFoundException.java b/src/main/java/roomescape/exception/NotFoundException.java new file mode 100644 index 000000000..e3207a0f6 --- /dev/null +++ b/src/main/java/roomescape/exception/NotFoundException.java @@ -0,0 +1,14 @@ +package roomescape.exception; + +public class NotFoundException extends RuntimeException { + private final ErrorCode errorCode; + + public NotFoundException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/loader/DataLoader.java b/src/main/java/roomescape/loader/DataLoader.java new file mode 100644 index 000000000..b58f8f91a --- /dev/null +++ b/src/main/java/roomescape/loader/DataLoader.java @@ -0,0 +1,24 @@ +package roomescape.loader; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import roomescape.member.Member; +import roomescape.member.MemberDao; +import roomescape.member.MemberRole; + +@Component +@Profile("!test") +public class DataLoader implements CommandLineRunner { + private final MemberDao memberDao; + + public DataLoader(MemberDao memberDao) { + this.memberDao = memberDao; + } + + @Override + public void run(String... args) { + memberDao.save(new Member("어드민", "admin@email.com", "password", MemberRole.ADMIN)); + memberDao.save(new Member("브라운", "brown@email.com", "password", MemberRole.USER)); + } +} diff --git a/src/main/java/roomescape/loader/TestDataLoader.java b/src/main/java/roomescape/loader/TestDataLoader.java new file mode 100644 index 000000000..4682ea223 --- /dev/null +++ b/src/main/java/roomescape/loader/TestDataLoader.java @@ -0,0 +1,61 @@ +package roomescape.loader; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import roomescape.member.Member; +import roomescape.member.MemberDao; +import roomescape.member.MemberRole; +import roomescape.reservation.Reservation; +import roomescape.reservation.ReservationDao; +import roomescape.theme.Theme; +import roomescape.theme.ThemeDao; +import roomescape.time.Time; +import roomescape.time.TimeDao; + +import java.util.List; + +@Component +@Profile("test") +public class TestDataLoader implements CommandLineRunner { + private final MemberDao memberDao; + private final ThemeDao themeDao; + private final TimeDao timeDao; + private final ReservationDao reservationDao; + + public TestDataLoader(MemberDao memberDao, ThemeDao themeDao, TimeDao timeDao, + ReservationDao reservationDao) { + this.memberDao = memberDao; + this.themeDao = themeDao; + this.timeDao = timeDao; + this.reservationDao = reservationDao; + } + + @Override + public void run(String... args) { + List members = memberDao.saveAll(List.of( + new Member("어드민", "admin@email.com", "password", MemberRole.ADMIN), + new Member("브라운", "brown@email.com", "password", MemberRole.USER) + )); + List themes = themeDao.saveAll(List.of( + new Theme("테마1", "테마1입니다."), + new Theme("테마2", "테마2입니다."), + new Theme("테마3", "테마3입니다.") + )); + List