Skip to content

Commit

Permalink
[1 - 2단계 방탈출 예약 대기] 이든(최승준) 미션 제출합니다. (#86)
Browse files Browse the repository at this point in the history
* init: 이전 미션 코드 반영

* refactor: Jdbc -> Jpa 마이그레이션

* fix: 인기테마 조회 API Endpoint 변경

* feat: 내 예약목록 조회 API 구현

* style: 처리한 TODO 제거

* style: 처리한 TODO 제거

* refactor: LAZY 로딩 적용

* refactor: Spring-validation 적용

* fix: 중복 메서드 제거

* fix: 코드라인 정리

* refactor: JPA Column Nullable 설정

* refactor: AuthInterceptor 분리

* refactor: 인증 Interceptor 코드 라인 정리

* feat: 회원가입 API 구현

* feat: Cookie MaxAge 설정

* feat: Logout API 구현

* refactor: 적용하지 않은 TODO 제거

* docs: auth API 관련 문서 작성

* refactor: Entity 객체 원시타입 필드에 VO 적용

* fix: 검증 기준 획일화

* feat: Reservation 객체 검증 로직 추가

* test: Member, Theme 객체 검증 로직 작성

* refactor: nativeQuery 사용 로직 제거

* fix: 누락된 Embeddable 에노테이션 추가

* docs: ERD 추가

* refactor: VO 필드명 도메인적으로 변경

- AttributeOverride 애노테이션 제거를 위한 목적

* test: Auth 관련 기능 테스트 구현

* refactor: ReservationTime에서 지난 시간인지 직접 검증하도록 수정

* refactor: 내 예약목록 조회 API Endpoint를 RESTful하게 변경

* test: 내 예약목록 조회 API 테스트 작성
  • Loading branch information
PgmJun authored May 18, 2024
1 parent fbd90e8 commit 5cc68d4
Show file tree
Hide file tree
Showing 117 changed files with 7,113 additions and 18 deletions.
521 changes: 521 additions & 0 deletions README.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
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 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-gson:0.11.2'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'

implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'

runtimeOnly 'com.h2database:h2'

Expand Down
1 change: 1 addition & 0 deletions src/main/java/roomescape/RoomescapeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
public class RoomescapeApplication {
Expand Down
98 changes: 98 additions & 0 deletions src/main/java/roomescape/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package roomescape.auth.controller;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
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.auth.dto.LoginCheckResponse;
import roomescape.auth.dto.LoginRequest;
import roomescape.auth.dto.SignUpRequest;
import roomescape.auth.service.AuthService;
import roomescape.global.auth.annotation.Auth;
import roomescape.global.auth.annotation.MemberId;
import roomescape.global.auth.jwt.JwtHandler;
import roomescape.global.auth.jwt.dto.TokenDto;
import roomescape.global.dto.response.ApiResponse;

@RestController
public class AuthController {
private final AuthService authService;

public AuthController(final AuthService authService) {
this.authService = authService;
}

@PostMapping("/signup")
public ApiResponse<Void> signup(@Valid @RequestBody final SignUpRequest signupRequest, final HttpServletResponse response) {
TokenDto tokenDto = authService.signUp(signupRequest);
addTokensToCookie(tokenDto, response);

return ApiResponse.success();
}

@PostMapping("/login")
public ApiResponse<Void> login(@Valid @RequestBody final LoginRequest loginRequest, final HttpServletResponse response) {
TokenDto tokenDto = authService.login(loginRequest);
addTokensToCookie(tokenDto, response);

return ApiResponse.success();
}

@PostMapping("/logout")
public ApiResponse<Void> logout(final HttpServletResponse response) {
TokenDto logoutTokenDto = authService.logout();
addTokensToCookie(logoutTokenDto, response);

return ApiResponse.success();
}

@Auth
@GetMapping("/login/check")
public ApiResponse<LoginCheckResponse> checkLogin(@MemberId final Long memberId) {
LoginCheckResponse response = authService.checkLogin(memberId);
return ApiResponse.success(response);
}

// TODO: 토큰 재발급 자동화 로직 구현
@GetMapping("/token-reissue")
public ApiResponse<Void> reissueToken(final HttpServletRequest request, final HttpServletResponse response) {
TokenDto requestToken = getTokenFromCookie(request);

TokenDto tokenInfo = authService.reissueToken(requestToken.accessToken(), requestToken.refreshToken());
addTokensToCookie(tokenInfo, response);

return ApiResponse.success();
}

private TokenDto getTokenFromCookie(final HttpServletRequest request) {
String accessToken = "";
String refreshToken = "";
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals(JwtHandler.ACCESS_TOKEN_HEADER_KEY)) {
accessToken = cookie.getValue();
}
if (cookie.getName().equals(JwtHandler.REFRESH_TOKEN_HEADER_KEY)) {
refreshToken = cookie.getValue();
}
}

return new TokenDto(accessToken, refreshToken);
}

private void addTokensToCookie(TokenDto tokenInfo, HttpServletResponse response) {
addTokenToCookie(JwtHandler.ACCESS_TOKEN_HEADER_KEY, tokenInfo.accessToken(), response, JwtHandler.ACCESS_TOKEN_EXPIRE_TIME);
addTokenToCookie(JwtHandler.REFRESH_TOKEN_HEADER_KEY, tokenInfo.refreshToken(), response, JwtHandler.REFRESH_TOKEN_EXPIRE_TIME);
}

private void addTokenToCookie(String cookieName, String token, HttpServletResponse response, int maxAge) {
Cookie cookie = new Cookie(cookieName, token);
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);

response.addCookie(cookie);
}
}
6 changes: 6 additions & 0 deletions src/main/java/roomescape/auth/dto/LoginCheckResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package roomescape.auth.dto;

public record LoginCheckResponse(
String name
) {
}
13 changes: 13 additions & 0 deletions src/main/java/roomescape/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package roomescape.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@NotBlank(message = "이메일은 null 또는 공백일 수 없습니다.")
@Email(message = "이메일 형식이 일치하지 않습니다. (xxx@xxx.xxx)")
String email,
@NotBlank(message = "비밀번호는 null 또는 공백일 수 없습니다.")
String password
) {
}
27 changes: 27 additions & 0 deletions src/main/java/roomescape/auth/dto/SignUpRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package roomescape.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import roomescape.member.domain.Member;
import roomescape.member.domain.MemberName;

public record SignUpRequest(
@NotBlank(message = "회원명은 null 또는 공백일 수 없습니다.")
@Size(min = MemberName.MIN_LENGTH, max = MemberName.MAX_LENGTH, message = "회원명은 " + MemberName.MIN_LENGTH + "~" + MemberName.MAX_LENGTH + "글자 사이여야 합니다.")
String name,
@NotBlank(message = "이메일은 null 또는 공백일 수 없습니다.")
@Email(message = "이메일 형식이 일치하지 않습니다. (xxx@xxx.xxx)")
String email,
@NotBlank(message = "비밀번호는 null 또는 공백일 수 없습니다.")
String password
) {
public Member toMemberEntity() {
return new Member(
name,
email,
password
);
}

}
56 changes: 56 additions & 0 deletions src/main/java/roomescape/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package roomescape.auth.service;

import org.springframework.stereotype.Service;
import roomescape.auth.dto.LoginCheckResponse;
import roomescape.auth.dto.LoginRequest;
import roomescape.auth.dto.SignUpRequest;
import roomescape.global.auth.jwt.JwtHandler;
import roomescape.global.auth.jwt.dto.TokenDto;
import roomescape.global.exception.error.ErrorType;
import roomescape.global.exception.model.UnauthorizedException;
import roomescape.member.domain.Member;
import roomescape.member.service.MemberService;

@Service
public class AuthService {
private final MemberService memberService;
private final JwtHandler jwtHandler;

public AuthService(final MemberService memberService, final JwtHandler jwtHandler) {
this.memberService = memberService;
this.jwtHandler = jwtHandler;
}

public TokenDto signUp(final SignUpRequest signupRequest) {
Member member = memberService.addMember(signupRequest);

return jwtHandler.createToken(member.getId());
}

public TokenDto login(final LoginRequest request) {
Member member = memberService.findMemberByEmailAndPassword(request);

return jwtHandler.createToken(member.getId());
}

public LoginCheckResponse checkLogin(final Long memberId) {
Member member = memberService.findMemberById(memberId);

return new LoginCheckResponse(member.getName());
}

public TokenDto reissueToken(final String accessToken, final String refreshToken) {
try {
jwtHandler.validateToken(refreshToken);
} catch (UnauthorizedException e) {
throw new UnauthorizedException(ErrorType.INVALID_REFRESH_TOKEN, ErrorType.INVALID_REFRESH_TOKEN.getDescription(), e);
}

Long memberId = jwtHandler.getMemberIdFromTokenWithNotValidate(accessToken);
return jwtHandler.createToken(memberId);
}

public TokenDto logout() {
return jwtHandler.createLogoutToken();
}
}
11 changes: 11 additions & 0 deletions src/main/java/roomescape/global/auth/annotation/Admin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package roomescape.global.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Admin {
}
11 changes: 11 additions & 0 deletions src/main/java/roomescape/global/auth/annotation/Auth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package roomescape.global.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}
11 changes: 11 additions & 0 deletions src/main/java/roomescape/global/auth/annotation/MemberId.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package roomescape.global.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface MemberId {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package roomescape.global.auth.interceptor;

import jakarta.servlet.http.Cookie;
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.global.auth.annotation.Admin;
import roomescape.global.auth.jwt.JwtHandler;
import roomescape.global.auth.jwt.constant.JwtKey;
import roomescape.global.exception.error.ErrorType;
import roomescape.global.exception.model.ForbiddenException;
import roomescape.global.exception.model.UnauthorizedException;
import roomescape.member.domain.Member;
import roomescape.member.domain.Role;
import roomescape.member.service.MemberService;

@Component
public class AdminInterceptor implements HandlerInterceptor {
private final MemberService memberService;
private final JwtHandler jwtHandler;

public AdminInterceptor(final MemberService memberService, final JwtHandler jwtHandler) {
this.memberService = memberService;
this.jwtHandler = jwtHandler;
}

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (handlerMethod.getMethodAnnotation(Admin.class) == null) {
return true;
}

request.setAttribute(JwtKey.MEMBER_ID.getValue(), parseMemberIdFromRequest(request));
return true;
}

private Long parseMemberIdFromRequest(final HttpServletRequest request) {
String cookieHeader = request.getHeader("Cookie");
if (cookieHeader != null) {
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals(JwtHandler.ACCESS_TOKEN_HEADER_KEY)) {
String accessToken = cookie.getValue();
Long memberId = jwtHandler.getMemberIdFromTokenWithValidate(accessToken);
Member member = memberService.findMemberById(memberId);
checkRole(member);

return memberId;
}
}
}
throw new UnauthorizedException(ErrorType.INVALID_TOKEN, ErrorType.INVALID_TOKEN.getDescription());
}

private boolean checkRole(final Member member) {
if (member.isRole(Role.ADMIN)) {
return true;
}
throw new ForbiddenException(ErrorType.PERMISSION_DOES_NOT_EXIST,
String.format("회원 권한이 존재하지 않아 접근할 수 없습니다. [memberId: %d, Role: %s]", member.getId(), member.getRole()));
}
}
Loading

0 comments on commit 5cc68d4

Please sign in to comment.