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

6주차 기본과제 #6

Open
wants to merge 1 commit into
base: #4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions fourthSeminar/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ dependencies {
implementation 'mysql:mysql-connector-java:8.0.32'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
//JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,31 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
import sopt.org.fourthSeminar.common.dto.ApiResponse;
import sopt.org.fourthSeminar.exception.Error;
import sopt.org.fourthSeminar.exception.model.BadRequestException;
import sopt.org.fourthSeminar.exception.model.SoptException;

import java.util.Objects;

@RestControllerAdvice
//
public class ControllerExceptionAdvice {
/**
* Spring 예외 처리 과정
* [1] <ExceptionHandlerExceptionResolver> 동작
* 1. 예외가 발생한 컨트롤러 내부에 적합한 @ExceptionHandler가 있는 지 확인 후 처리
* 2. 없으면, ControllerAdvice로 넘어감
* 3. ControllerAdvice안에 적합한 @ExceptionHandler가 있으면 처리 후 없으면 다음 Resolver로 넘어감
* [2] <ResponseStatusExceptionResolver>가 동작함
* 1. @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사함
* 2. 맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달되고, 서블릿이 BasicErrorController로 요청을 전달함
* [3] <DefaultHandlerExceptionResolver>가 동작함 - 스프링 내부 기본 예외 처리
* - Spring의 내부 예외인지 검사하여 맞으면 에러를 처리하고 아니면 넘어감
* [4] 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고, 서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달함

!!가장 구체적인 예외 핸들러를 먼저 찾고 -> 없으면 부모 예외의 핸들러를 찾는다.

500에러는 주석 처리하고 최대한 많은 에러를 발생시켜보면서 꼼꼼하게 처리하기
*/
/**
* 400 BAD_REQUEST
*/
Expand All @@ -26,6 +45,12 @@ protected ApiResponse handleMethodArgumentNotValidException(final MethodArgument
return ApiResponse.error(Error.REQUEST_VALIDATION_EXCEPTION, String.format("%s. (%s)", fieldError.getDefaultMessage(), fieldError.getField()));
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BadRequestException.class)
protected ApiResponse handleBadRequestException(final BadRequestException e) {
return ApiResponse.error(e.getError(), e.getMessage());
}

/**
* 500 Internal Server
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package sopt.org.fourthSeminar.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import sopt.org.fourthSeminar.config.resolver.UserIdResolver;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer { //스프링에 직접 만든 Resolver를 등록

private final UserIdResolver userIdResolver;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userIdResolver);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package sopt.org.fourthSeminar.config.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sopt.org.fourthSeminar.exception.Error;
import sopt.org.fourthSeminar.exception.model.UnauthorizedException;

import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Service
public class JwtService {

@Value("${jwt.secret}")
private String jwtSecret;

@PostConstruct //의존성 주입이 이루어진 후 실행됨이 보장됨 -> 오직 한 번만 수행
protected void init() {
jwtSecret = Base64.getEncoder()
.encodeToString(jwtSecret.getBytes(StandardCharsets.UTF_8));
}

// JWT 토큰 발급
public String issuedToken(String userId) {
final Date now = new Date();

// 클레임 생성 -> payload
final Claims claims = Jwts.claims()
.setSubject("access_token")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + 120 * 60 * 1000L)); //1000 (밀리초) -> 2시간

//private claim 등록
claims.put("userId", userId);

return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setClaims(claims) //claim 지정
.signWith(getSigningKey()) //byte값으로 키 생성
.compact();
}

private Key getSigningKey() {
final byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}

// JWT 토큰 검증 (클라이언트가 가져옴)
public boolean verifyToken(String token) {
try {
final Claims claims = getBody(token);
return true;
} catch (RuntimeException e) {
if (e instanceof ExpiredJwtException) {
throw new UnauthorizedException(Error.TOKEN_TIME_EXPIRED_EXCEPTION, Error.TOKEN_TIME_EXPIRED_EXCEPTION.getMessage());
}
return false;
}
}

private Claims getBody(final String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}

// JWT 토큰 내용 확인 -> Long으로 형변환 필요
public String getJwtContents(String token) {
final Claims claims = getBody(token);
return (String) claims.get("userId");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sopt.org.fourthSeminar.config.resolver;

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

@Target(ElementType.PARAMETER) //어노테이션이 사용될 위치를 parameter로 지정
@Retention(RetentionPolicy.RUNTIME) //컴파일 이후 런타임 동안 참조 가능 -> 어노테이션이 유효
public @interface UserId { //@userId 생성
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package sopt.org.fourthSeminar.config.resolver;

import lombok.RequiredArgsConstructor;
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;
import sopt.org.fourthSeminar.config.jwt.JwtService;
import sopt.org.fourthSeminar.exception.Error;
import sopt.org.fourthSeminar.exception.model.UnauthorizedException;

import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;

@RequiredArgsConstructor
@Component //타입기반의 자동주입 어노테이션 -> 등록된 Bean 객체 가져옴

//HandlerMethodArgumentResolver: 주어진 요청을 처리할 때, 메소드 파라미터를 인자값들에 주입 해주는 전략 인터페이스
public class UserIdResolver implements HandlerMethodArgumentResolver {

private final JwtService jwtService;

//핸들러(컨트롤러 메소드)의 특정 파라미터를 지원하는지 여부를 판단하기 위한 메소드
//어떤 파라미터에 대한 작업을 수행할 것인지 지정
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(UserId.class) && Long.class.equals(parameter.getParameterType());
}

//파라미터에 대한 로직 수행
@Override
public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewContainer mavContainer, @NotNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String token = request.getHeader("Authorization");

//토큰 검증
if (!jwtService.verifyToken(token)) {
throw new UnauthorizedException(Error.UNAUTHORIZED_TOKEN_EXCEPTION, Error.UNAUTHORIZED_TOKEN_EXCEPTION.getMessage());
}

//유저 아이디 반환
final String tokenContents = jwtService.getJwtContents(token);
try {
return Long.parseLong(tokenContents);
} catch (NumberFormatException e) {
throw new RuntimeException(String.format("USER_ID를 가져오지 못했습니다. (%s - %s)", parameter.getClass(), parameter.getMethod()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import sopt.org.fourthSeminar.common.dto.ApiResponse;
import sopt.org.fourthSeminar.controller.dto.BoardRequestDto;
import sopt.org.fourthSeminar.config.jwt.JwtService;
import sopt.org.fourthSeminar.config.resolver.UserId;
import sopt.org.fourthSeminar.controller.request.dto.BoardRequestDto;
import sopt.org.fourthSeminar.exception.Success;
import sopt.org.fourthSeminar.service.BoardService;

Expand All @@ -16,11 +18,23 @@
public class BoardController {

private final BoardService boardService;
private final JwtService jwtService;

// @PostMapping("/create")
// @ResponseStatus(HttpStatus.CREATED)
// public ApiResponse create(
// @RequestHeader("Authorization") String accessToken,
// @RequestBody @Valid final BoardRequestDto request) {
// boardService.create(Long.parseLong(jwtService.getJwtContents(accessToken)), request);
// return ApiResponse.success(Success.CREATE_BOARD_SUCCESS);
// }

@PostMapping("/create")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse create(@RequestBody @Valid final BoardRequestDto request) {
boardService.create(request);
public ApiResponse create(
@UserId Long userId,
@RequestBody @Valid final BoardRequestDto request) {
boardService.create(userId, request);
return ApiResponse.success(Success.CREATE_BOARD_SUCCESS);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import sopt.org.fourthSeminar.common.dto.ApiResponse;
import sopt.org.fourthSeminar.controller.dto.UserRequestDto;
import sopt.org.fourthSeminar.controller.dto.UserResponseDto;
import sopt.org.fourthSeminar.config.jwt.JwtService;
import sopt.org.fourthSeminar.controller.request.dto.UserRequestDto;
import sopt.org.fourthSeminar.controller.response.dto.UserResponseDto;
import sopt.org.fourthSeminar.controller.request.dto.UserLoginRequestDto;
import sopt.org.fourthSeminar.controller.response.dto.UserLoginResponseDto;
import sopt.org.fourthSeminar.exception.Success;
import sopt.org.fourthSeminar.service.UserService;

Expand All @@ -16,10 +19,19 @@
@RequestMapping("/user")
public class UserController {
private final UserService userService;
private final JwtService jwtService;

@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<UserResponseDto> create(@RequestBody @Valid final UserRequestDto request) {
return ApiResponse.success(Success.SIGNUP_SUCCESS, userService.create(request));
}

@PostMapping("/login")
@ResponseStatus(HttpStatus.OK)
public ApiResponse<UserLoginResponseDto> login(@RequestBody @Valid final UserLoginRequestDto request) {
final Long userId = userService.login(request);
final String token = jwtService.issuedToken(String.valueOf(userId));
return ApiResponse.success(Success.LOGIN_SUCCESS, UserLoginResponseDto.of(userId, token));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sopt.org.fourthSeminar.controller.dto;
package sopt.org.fourthSeminar.controller.request.dto;

import lombok.AccessLevel;
import lombok.Getter;
Expand All @@ -12,8 +12,8 @@
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class BoardRequestDto {

@Email(message = "이메일 형식에 맞지 않습니다")
private String email;
// @Email(message = "이메일 형식에 맞지 않습니다")
// private String email;

@NotBlank
private String title;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package sopt.org.fourthSeminar.controller.request.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UserLoginRequestDto {

@Email(message = "이메일 형식에 맞지 않습니다")
@NotBlank
private String email;

@NotNull
@Pattern(
regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}",
message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자의 비밀번호여야 합니다"
)
private String password;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sopt.org.fourthSeminar.controller.dto;
package sopt.org.fourthSeminar.controller.request.dto;

import lombok.AccessLevel;
import lombok.Getter;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package sopt.org.fourthSeminar.controller.response.dto;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class UserLoginResponseDto {
private Long userId;
private String accessToken;

public static UserLoginResponseDto of(Long userId, String accessToken) {
return new UserLoginResponseDto(userId, accessToken);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sopt.org.fourthSeminar.controller.dto;
package sopt.org.fourthSeminar.controller.response.dto;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand Down
Loading