diff --git a/build.gradle b/build.gradle index 0617184..0022007 100644 --- a/build.gradle +++ b/build.gradle @@ -24,13 +24,28 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Web MVC implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + // DB + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + // TEST testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // OAUTH2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/src/main/java/com/example/gtable/GTableApplication.java b/src/main/java/com/example/gtable/GTableApplication.java index 700b9fc..d75aac7 100644 --- a/src/main/java/com/example/gtable/GTableApplication.java +++ b/src/main/java/com/example/gtable/GTableApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class GTableApplication { - public static void main(String[] args) { - SpringApplication.run(GTableApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(GTableApplication.class, args); + } } diff --git a/src/main/java/com/example/gtable/TODO/TodoController.java b/src/main/java/com/example/gtable/TODO/TodoController.java deleted file mode 100644 index bf74bd9..0000000 --- a/src/main/java/com/example/gtable/TODO/TodoController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.gtable.TODO; - -import com.example.gtable.global.api.ApiUtils; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class TodoController { - private TodoService todoService; - @GetMapping("TODO") - public ResponseEntity createBoard() { - return ResponseEntity - .status(HttpStatus.CREATED) - .body( - ApiUtils.success( - todoService.add() - ) - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/gtable/TODO/TodoRepository.java b/src/main/java/com/example/gtable/TODO/TodoRepository.java deleted file mode 100644 index 5b44108..0000000 --- a/src/main/java/com/example/gtable/TODO/TodoRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.gtable.TODO; - -import org.springframework.stereotype.Repository; - -@Repository -public interface TodoRepository { -} diff --git a/src/main/java/com/example/gtable/TODO/TodoService.java b/src/main/java/com/example/gtable/TODO/TodoService.java deleted file mode 100644 index 61a198b..0000000 --- a/src/main/java/com/example/gtable/TODO/TodoService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.gtable.TODO; - -import org.springframework.stereotype.Service; - -@Service -public class TodoService { - private final TodoRepository todoRepository; - - public TodoService(TodoRepository todoRepository) { - this.todoRepository = todoRepository; - } - - public String add() { - String todo = "TODO"; - String todoList = "TODO LIST"; - return todo; - } -} diff --git a/src/main/java/com/example/gtable/global/api/ApiError.java b/src/main/java/com/example/gtable/global/api/ApiError.java index f2026b6..cb5ffc0 100644 --- a/src/main/java/com/example/gtable/global/api/ApiError.java +++ b/src/main/java/com/example/gtable/global/api/ApiError.java @@ -1,23 +1,24 @@ package com.example.gtable.global.api; -import lombok.Getter; import org.springframework.http.HttpStatus; +import lombok.Getter; + @Getter public class ApiError { - private final String message; - private final int status; + private final String message; + private final int status; - public ApiError(String message, int status) { - this.message = message; - this.status = status; - } + public ApiError(String message, int status) { + this.message = message; + this.status = status; + } - public ApiError(Throwable throwable, HttpStatus status) { - this(throwable.getMessage(), status); - } + public ApiError(Throwable throwable, HttpStatus status) { + this(throwable.getMessage(), status); + } - public ApiError(String message, HttpStatus status) { - this(message, status.value()); - } + public ApiError(String message, HttpStatus status) { + this(message, status.value()); + } } diff --git a/src/main/java/com/example/gtable/global/api/ApiResult.java b/src/main/java/com/example/gtable/global/api/ApiResult.java index ce4684e..6a75a71 100644 --- a/src/main/java/com/example/gtable/global/api/ApiResult.java +++ b/src/main/java/com/example/gtable/global/api/ApiResult.java @@ -1,21 +1,21 @@ package com.example.gtable.global.api; public class ApiResult { - private final boolean success; - private final T response; - private final ApiError error; + private final boolean success; + private final T response; + private final ApiError error; - public ApiResult(boolean success, T response, ApiError error) { - this.success = success; - this.response = response; - this.error = error; - } + public ApiResult(boolean success, T response, ApiError error) { + this.success = success; + this.response = response; + this.error = error; + } - public boolean isSuccess() { - return success; - } + public boolean isSuccess() { + return success; + } - public T getResponse() { - return response; - } + public T getResponse() { + return response; + } } diff --git a/src/main/java/com/example/gtable/global/api/ApiUtils.java b/src/main/java/com/example/gtable/global/api/ApiUtils.java index a472b96..3ee9f10 100644 --- a/src/main/java/com/example/gtable/global/api/ApiUtils.java +++ b/src/main/java/com/example/gtable/global/api/ApiUtils.java @@ -1,20 +1,21 @@ package com.example.gtable.global.api; +import org.springframework.http.HttpStatus; + import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.http.HttpStatus; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApiUtils { - public static ApiResult success(T response) { - return new ApiResult<>(true, response,null); - } + public static ApiResult success(T response) { + return new ApiResult<>(true, response, null); + } - public static ApiResult error(Throwable throwable, HttpStatus status) { - return new ApiResult<>(false, null, new ApiError(throwable, status)); - } + public static ApiResult error(Throwable throwable, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(throwable, status)); + } - public static ApiResult error(String message,HttpStatus status) { - return new ApiResult<>(false, null, new ApiError(message,status)); - } + public static ApiResult error(String message, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(message, status)); + } } diff --git a/src/main/java/com/example/gtable/global/config/CorsConfig.java b/src/main/java/com/example/gtable/global/config/CorsConfig.java new file mode 100644 index 0000000..1c02f40 --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.example.gtable.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 + config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 출처 설정 + config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 + config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 + config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 + // config.setAllowCredentials(true); // 쿠키 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + return source; + } +} diff --git a/src/main/java/com/example/gtable/global/config/SecurityConfig.java b/src/main/java/com/example/gtable/global/config/SecurityConfig.java new file mode 100644 index 0000000..8c817cc --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.example.gtable.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.example.gtable.global.security.jwt.JwtAuthorizationFilter; +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.global.security.oauth2.CustomOAuth2UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity // security 활성화 어노테이션 +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final com.example.gtable.global.security.oauth2.OAuth2LoginSuccessHandler OAuth2LoginSuccessHandler; + private final JwtUtil jwtUtil; + + private final CorsConfigurationSource corsConfigurationSource; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) + .csrf(AbstractHttpConfigurer::disable) + // 시큐리티 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + // oauth2 로그인 + // - userInfoEndPoint에서 사용자 정보 불러오고, + // - successHandler에서 로그인 성공 시 JWT 생성 및 반환로직 + .oauth2Login(oauth2 -> + oauth2.userInfoEndpoint(userInfoEndpoint -> + userInfoEndpoint.userService(customOAuth2UserService) + ).successHandler(OAuth2LoginSuccessHandler) + ) + // 세션 사용하지 않음 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/authorization/kakao", // 카카오 로그인 요청 + "/login/oauth2/code/**", // 카카오 인증 콜백 + "/api/refresh-token") // refresh token (토큰 갱신) + .permitAll() + .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 + ) + // JWTFiler + .addFilterBefore(new JwtAuthorizationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/BusinessException.java b/src/main/java/com/example/gtable/global/security/exception/BusinessException.java new file mode 100644 index 0000000..118ef78 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.example.gtable.global.security.exception; + +public abstract class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected BusinessException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java b/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java new file mode 100644 index 0000000..93b9467 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java @@ -0,0 +1,22 @@ +package com.example.gtable.global.security.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + // global + INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "g001"), + + // auth + UNAUTHORIZED("권한이 없습니다", "a001"), + + // token + REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "t001"), + DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "t002"); + + private final String message; + private final String code; + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java b/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java new file mode 100644 index 0000000..305e2cd --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.example.gtable.global.security.exception; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String message; + private final String code; + private final Map errors; + + public ErrorResponse(String message, String code) { + this.message = message; + this.code = code; + errors = new HashMap<>(); + } + + public ErrorResponse(String message, String code, Map errors) { + this.message = message; + this.code = code; + this.errors = errors; + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java b/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a8f4ad8 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java @@ -0,0 +1,105 @@ +package com.example.gtable.global.security.exception; + +import static com.example.gtable.global.security.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; +import static org.springframework.http.HttpStatus.*; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAUTH 인증 실패 에러처리 메서드 + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { + log.error("handleOAuth2AuthenticationException", e); + + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e) { + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + log.error("handleUnauthorizedExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e) { + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java b/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java new file mode 100644 index 0000000..65472b4 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.gtable.global.security.exception; + +public class RefreshTokenNotFoundException extends ResourceNotFoundException { + + public RefreshTokenNotFoundException() { + super(ErrorMessage.REFRESH_TOKEN_NOT_FOUND); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java b/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..32ff882 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java @@ -0,0 +1,14 @@ +package com.example.gtable.global.security.exception; + +public abstract class ResourceNotFoundException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected ResourceNotFoundException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java b/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java new file mode 100644 index 0000000..38df8e0 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java @@ -0,0 +1,8 @@ +package com.example.gtable.global.security.exception; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java b/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java new file mode 100644 index 0000000..6621357 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.example.gtable.global.security.exception; + +public class UnauthorizedException extends RuntimeException { + private final ErrorMessage errorMessage; + + public UnauthorizedException() { + super(ErrorMessage.UNAUTHORIZED.getMessage()); + this.errorMessage = ErrorMessage.UNAUTHORIZED; + } + + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java b/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java new file mode 100644 index 0000000..bce7891 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,93 @@ +package com.example.gtable.global.security.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.user.entity.Role; +import com.example.gtable.user.entity.SocialType; +import com.example.gtable.user.entity.User; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// JWT 검증 필터 +// 1. 헤더에서 accessToken 추출, 2. 토큰 검증, 3. 유효하면 사용자정보를 SecurityContextHolder에 세팅 +// 그러면, 이후 컨트롤러에서 @AuthenticationPrincipal에서 저장했던 사용자 정보를 꺼내쓸 수 있음 +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + // 인증헤더 Bearer가 없다면, 다음 필터로 넘김 + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + + log.info("JwtAuthorizationFilter 1 "); + return; + } + + log.info("header :: {}, header.substring(7) :: {}", header, header.substring(7)); + String accessToken = header.substring(7); + + // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음 + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + // response status code + msg + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("access token expired"); + + log.info("JwtAuthorizationFilter 2 "); + return; + } + + // 토큰이 accessToken 종류인지 확인 + String tokenCategory = jwtUtil.getTokenCategory(accessToken); + + if (!tokenCategory.equals("accessToken")) { + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid access token"); + + log.info("JwtAuthorizationFilter 3 "); + return; + } + + // userId와 role 값 추출 + Long userId = jwtUtil.getUserId(accessToken); + String roleString = jwtUtil.getRole(accessToken); + + User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" + , SocialType.KAKAO, Role.fromString(roleString)); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(user); + + // 스프링 시큐리티 인증 토큰 생성 + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, customOAuth2User.getAuthorities()); + + // 생성한 인증 정보를 SecurityContext에 설정 + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + + log.info("JwtAuthorizationFilter 4 "); + + filterChain.doFilter(request, response); + + } + +} diff --git a/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java b/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java new file mode 100644 index 0000000..b32846f --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java @@ -0,0 +1,76 @@ +package com.example.gtable.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Jwts; + +@Component +public class JwtUtil { + private final SecretKey secretKey; + + // 시크릿 키를 암호화하여, 키 생성 + public JwtUtil(@Value("${jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + public String createAccessToken(String tokenCategory, Long userId, String role, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // accessToken + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(String tokenCategory, Long userId, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // refreshToken + .claim("userId", userId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String getTokenCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("tokenCategory", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + } + + public Long getUserId(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("userId", Long.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..0cb6aab --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,70 @@ +package com.example.gtable.global.security.oauth2; + +import java.util.Optional; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.global.security.oauth2.dto.KaKaoResponse; +import com.example.gtable.global.security.oauth2.dto.OAuth2Response; +import com.example.gtable.user.entity.Role; +import com.example.gtable.user.entity.SocialType; +import com.example.gtable.user.entity.User; +import com.example.gtable.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// OAuth2 제공자(카카오)로부터 제공받은 사용자 정보를, 우리 서비스에 맞게 가공, 변환 +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + log.info("CustomOAuth2UserService :: {}", oAuth2User); + log.info("oAuthUser.getAttributes :: {}", oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if (registrationId.equals("kakao")) { + oAuth2Response = new KaKaoResponse(oAuth2User.getAttributes()); + } else { + throw new OAuth2AuthenticationException("지원하지 않는 OAuth2 Provider 입니다."); + } + + // DB에 유저가 있는지 판단 + Optional foundUser = userRepository.findByEmail(oAuth2Response.getEmail()); + + // DB에 유저 없으면 - 회원가입 + if (foundUser.isEmpty()) { + + User user = User.builder() + .email(oAuth2Response.getEmail()) + .nickname(oAuth2Response.getNickName()) + .profileImage(oAuth2Response.getProfileImage()) + .socialType(SocialType.KAKAO) + .role(Role.USER) // 일반 유저 설정 + .build(); + + userRepository.save(user); + + return new CustomOAuth2User(user); + } else { + // DB에 유저 존재하면 - 로그인 진행 (이때 로그인 처리는 안하고, OAuth2LoginSuccessHandler에서 담당함) + User user = foundUser.get(); + + return new CustomOAuth2User(user); + } + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java b/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..60ce2a6 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,81 @@ +package com.example.gtable.global.security.oauth2; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.token.entity.Token; +import com.example.gtable.token.repository.TokenRepository; +import com.example.gtable.user.entity.User; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// 카카오 로그인 성공 시, 콜백 핸들러 +// 1. JWT 토큰 발급 +// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 +// 2. refreshToken만 DB에 저장 +// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 + CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); + + User user = customUserDetails.getUser(); + Long userId = customUserDetails.getUserId(); + String email = customUserDetails.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); + + // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 + String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); // 유효기간 30분 + String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, + 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 + + // 3. refreshToken을 DB에 저장 + Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(refreshTokenEntity); + + // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 + String body = objectMapper.writeValueAsString( + Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + ) + ); + response.getWriter().write(body); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java new file mode 100644 index 0000000..32e9f94 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java @@ -0,0 +1,60 @@ +package com.example.gtable.global.security.oauth2.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.example.gtable.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + private User user; + + // User 객체를 받는 생성자 + public CustomOAuth2User(User user) { + this.user = user; + } + + @Override + public Map getAttributes() { + return null; + } + + // 사용자가 가지는 권한 설정 + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().getName(); // 유저의 권한 리턴 + } + }); + + return authorities; + } + + @Override + public String getName() { + return user.getEmail(); + } + + public User getUser() { + return user; + } + + public Long getUserId() { + return user.getId(); + } + + public String getNickname() { + return user.getNickname(); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java new file mode 100644 index 0000000..f7d06b1 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java @@ -0,0 +1,40 @@ +package com.example.gtable.global.security.oauth2.dto; + +import java.util.Map; + +import lombok.RequiredArgsConstructor; + +// 카카오 OAuth2 응답에서 필요한 정보를 추출하는 역할 +@RequiredArgsConstructor +public class KaKaoResponse implements OAuth2Response { + private final Map attributes; + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map)attributes.get("kakao_account"); + return kakaoAccount.get("email").toString(); + } + + @Override + public String getNickName() { + Map properties = (Map)attributes.get("properties"); + return properties.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + Map properties = (Map)attributes.get("properties"); + return properties.get("profile_image").toString(); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java new file mode 100644 index 0000000..919c49e --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java @@ -0,0 +1,19 @@ +package com.example.gtable.global.security.oauth2.dto; + +public interface OAuth2Response { + // 제공자 (ex. naver, kakao) + String getProvider(); + + // 제공자에서 발급해주는 아이디 (번호) + String getProviderId(); + + // 아래 이메일, 닉네임, 프로필이미지는 내가 카카오 developers에서 발급받겠다고 신청한 정보들이다 + // 이메일 + String getEmail(); + + // 닉네임 + String getNickName(); + + // 프로필이미지 + String getProfileImage(); +} diff --git a/src/main/java/com/example/gtable/token/controller/TokenController.java b/src/main/java/com/example/gtable/token/controller/TokenController.java new file mode 100644 index 0000000..1627480 --- /dev/null +++ b/src/main/java/com/example/gtable/token/controller/TokenController.java @@ -0,0 +1,49 @@ +package com.example.gtable.token.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.token.dto.AuthenticationResponse; +import com.example.gtable.token.dto.RefreshTokenRequest; +import com.example.gtable.token.service.TokenService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/refresh-token") +@Slf4j +public class TokenController { + private final JwtUtil jwtUtil; + private final TokenService tokenService; + @PostMapping + public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ + String refreshToken = request.getRefreshToken(); + + // 리프레시 토큰 검증 + Long userId = jwtUtil.getUserId(refreshToken); + String role = jwtUtil.getRole(refreshToken); + + // 리프레시 토큰 유효성 검증 + if (tokenService.validateToken(refreshToken, userId)){ + // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 + String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); + String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30 * 24 * 60 * 60 * 1000L); + + // DB에 새로운 refreshToken으로 교체 + tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); + + AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, refreshToken); + return ResponseEntity.ok().body(authenticationResponse); + + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); + } +} diff --git a/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java b/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java new file mode 100644 index 0000000..1d8b270 --- /dev/null +++ b/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java @@ -0,0 +1,17 @@ +package com.example.gtable.token.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@ToString(exclude = {"accessToken", "refreshToken"}) // 로깅 시 토큰 노출 방지 +public class AuthenticationResponse { + @JsonProperty("access_token") + private final String accessToken; + + @JsonProperty("refresh_token") + private final String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java b/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..7966e52 --- /dev/null +++ b/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java @@ -0,0 +1,12 @@ +package com.example.gtable.token.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class RefreshTokenRequest { + @NotBlank(message = "Refresh token은 필수입니다.") + private String refreshToken; +} diff --git a/src/main/java/com/example/gtable/token/entity/Token.java b/src/main/java/com/example/gtable/token/entity/Token.java new file mode 100644 index 0000000..30d407d --- /dev/null +++ b/src/main/java/com/example/gtable/token/entity/Token.java @@ -0,0 +1,54 @@ +package com.example.gtable.token.entity; + +import java.time.LocalDateTime; + +import com.example.gtable.user.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "token") +@Getter +public class Token { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tokenId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column + private String refreshToken; + + private LocalDateTime expiredDate; + + @Builder + public Token(User user, String refreshToken, LocalDateTime expiredDate) { + this.user = user; + this.refreshToken = refreshToken; + this.expiredDate = expiredDate; + } + + // static method로 객체를 생성 - 생성 의도 파악 쉬웁 + public static Token toEntity(User user, String refreshToken, LocalDateTime expiredDate){ + return Token.builder() + .user(user) + .refreshToken(refreshToken) + .expiredDate(expiredDate) + .build(); + } + +} diff --git a/src/main/java/com/example/gtable/token/repository/TokenRepository.java b/src/main/java/com/example/gtable/token/repository/TokenRepository.java new file mode 100644 index 0000000..61a24d3 --- /dev/null +++ b/src/main/java/com/example/gtable/token/repository/TokenRepository.java @@ -0,0 +1,11 @@ +package com.example.gtable.token.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.gtable.token.entity.Token; + +public interface TokenRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/example/gtable/token/service/TokenService.java b/src/main/java/com/example/gtable/token/service/TokenService.java new file mode 100644 index 0000000..4e0843e --- /dev/null +++ b/src/main/java/com/example/gtable/token/service/TokenService.java @@ -0,0 +1,66 @@ +package com.example.gtable.token.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gtable.global.security.exception.RefreshTokenNotFoundException; +import com.example.gtable.global.security.exception.TokenBadRequestException; +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.token.entity.Token; +import com.example.gtable.token.repository.TokenRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenService { + private final TokenRepository tokenRepository; + private final JwtUtil jwtUtil; + + @Transactional + public Boolean validateToken(String token, Long userId){ + // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. + Optional savedToken = tokenRepository.findByUserId(userId); + + // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 + if (savedToken.isEmpty()){ + log.info("여기에 걸렸니 ? -- 1 "); + return false; + } + + // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 + if (!savedToken.get().getRefreshToken().equals(token)){ + log.info("여기에 걸렸니 ? -- 2 "); + return false; + } + + // 리프레시 토큰의 만료여부 확인 + if(jwtUtil.isExpired(token)){ + log.info("여기에 걸렸니 ? -- 3 "); + return false; // 만료된 토큰은 유효하지 않음 + } + + log.info("여기에 걸렸니 ? -- 4 "); + return true; // 모든 조건 만족 시, 유효한 토큰 + } + + @Transactional + public void updateRefreshToken(Long userId, String oldRefreshToken, String newRefreshToken){ + Token token = tokenRepository.findByUserId(userId) + .orElseThrow(RefreshTokenNotFoundException::new); // 404 + + if (!token.getRefreshToken().equals(oldRefreshToken)){ + throw new TokenBadRequestException(); // 400 + } + + // 기존 토큰 삭제 및 새 토큰 저장 + tokenRepository.delete(token); + Token newToken = Token.toEntity(token.getUser(), newRefreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(newToken); + } +} diff --git a/src/main/java/com/example/gtable/user/controller/UserController.java b/src/main/java/com/example/gtable/user/controller/UserController.java new file mode 100644 index 0000000..f675866 --- /dev/null +++ b/src/main/java/com/example/gtable/user/controller/UserController.java @@ -0,0 +1,35 @@ +package com.example.gtable.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.user.dto.UserResponseDto; +import com.example.gtable.user.entity.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Slf4j +public class UserController { + + // 로그인된 유저 정보를 확인하는 api + @GetMapping("/me") + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomOAuth2User customOAuth2User){ + User user = customOAuth2User.getUser(); + + UserResponseDto userResponseDto = UserResponseDto.builder() + .userId(user.getId()) + .role(user.getRole().getName()) + .build(); + return ResponseEntity.ok(userResponseDto); + } + +} diff --git a/src/main/java/com/example/gtable/user/dto/UserResponseDto.java b/src/main/java/com/example/gtable/user/dto/UserResponseDto.java new file mode 100644 index 0000000..d9f256b --- /dev/null +++ b/src/main/java/com/example/gtable/user/dto/UserResponseDto.java @@ -0,0 +1,18 @@ +package com.example.gtable.user.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Builder +@Getter +@RequiredArgsConstructor +public class UserResponseDto { + @JsonProperty("userId") + private final Long userId; + @JsonProperty("role") + private final String role; + +} diff --git a/src/main/java/com/example/gtable/user/entity/Role.java b/src/main/java/com/example/gtable/user/entity/Role.java new file mode 100644 index 0000000..3095eb9 --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/Role.java @@ -0,0 +1,27 @@ +package com.example.gtable.user.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + USER("USER"), + ADMIN("ADMIN"); + + private final String name; + + // Role의 String name -> Role enum으로 변경 + public static Role fromString(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Role name cannot be null or empty"); + } + + for (Role role : Role.values()){ + if (role.name.equalsIgnoreCase(name)){ + return role; + } + } + throw new IllegalArgumentException("Unknown role: " + name); + } +} diff --git a/src/main/java/com/example/gtable/user/entity/SocialType.java b/src/main/java/com/example/gtable/user/entity/SocialType.java new file mode 100644 index 0000000..90e5fad --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/SocialType.java @@ -0,0 +1,5 @@ +package com.example.gtable.user.entity; + +public enum SocialType { + KAKAO, NAVER, GOOGLE +} diff --git a/src/main/java/com/example/gtable/user/entity/User.java b/src/main/java/com/example/gtable/user/entity/User.java new file mode 100644 index 0000000..05b0783 --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/User.java @@ -0,0 +1,65 @@ +package com.example.gtable.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +@Getter +public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 생성 + private Long id; + + @Column(nullable = false, unique = true) + private String email; // 카카오 이메일 + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private String profileImage; + + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Enumerated(EnumType.STRING) + private Role role; + + @Builder + public User(String email, String nickname, String profileImage, SocialType socialType, Role role){ + this.email = email; + this.nickname = nickname; + this.profileImage = profileImage; + this.socialType = socialType; + this.role = role; + } + + public static User createUserWithId(Long userId, String email, String nickname, String profileImage, SocialType socialType, Role role){ + User user = User.builder() + .email(email) + .nickname(nickname) + .profileImage(profileImage) + .socialType(socialType) + .role(role) + .build(); + user.id = userId; + + return user; + } + + // User 도메인 관련 비즈니스 로직 (예: 닉네임 변경) + public void updateNickname(String nickname){ + this.nickname = nickname; + } +} diff --git a/src/main/java/com/example/gtable/user/repository/UserRepository.java b/src/main/java/com/example/gtable/user/repository/UserRepository.java new file mode 100644 index 0000000..46d699f --- /dev/null +++ b/src/main/java/com/example/gtable/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.example.gtable.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.gtable.user.entity.User; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/example/gtable/user/service/UserService.java b/src/main/java/com/example/gtable/user/service/UserService.java new file mode 100644 index 0000000..94ac857 --- /dev/null +++ b/src/main/java/com/example/gtable/user/service/UserService.java @@ -0,0 +1,14 @@ +package com.example.gtable.user.service; + +import org.springframework.stereotype.Service; + +import com.example.gtable.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + +}