diff --git a/build.gradle b/build.gradle index 259724f..3471706 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' //spring web implementation 'org.springframework.boot:spring-boot-starter-web' + //spring security + implementation 'org.springframework.boot:spring-boot-starter-security' + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' //javax validation implementation 'javax.validation:validation-api:2.0.1.Final' //swagger (spring 3.x버전이라 springfox 적용불가, springdoc 중에서 호환되는 종속성 사용) diff --git a/src/main/java/com/kusitms/jipbap/auth/AuthController.java b/src/main/java/com/kusitms/jipbap/auth/AuthController.java new file mode 100644 index 0000000..1e7bc32 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/AuthController.java @@ -0,0 +1,47 @@ +package com.kusitms.jipbap.auth; + +import com.kusitms.jipbap.auth.dto.*; +import com.kusitms.jipbap.security.Auth; +import com.kusitms.jipbap.security.AuthInfo; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "일반 회원 가입") + @PostMapping("/signUp") + @ResponseStatus(HttpStatus.OK) + public void signUp(@Valid @RequestBody SignUpRequestDto dto) { + authService.signUp(dto); + } + + @Operation(summary = "로그인") + @PostMapping("/signIn") + @ResponseStatus(HttpStatus.OK) + public SignInResponseDto signIn(@Valid @RequestBody SignInRequestDto dto) { + return authService.signIn(dto.getEmail(), dto.getPassword()); + } + + @Operation(summary = "카카오 회원 가입(로그인)") + @PostMapping("/kakao") + @ResponseStatus(HttpStatus.OK) + public SignInResponseDto kakaoVerification(@RequestBody KakaoSignInRequestDto dto) { + return authService.kakaoAutoSignIn(authService.getKakaoProfile(dto.getToken())); + } + + @Operation(summary = "액세스 토큰 재발급 - 헤더에 refreshToken 정보 포함하여 요청") + @PostMapping("/reissue") + public ReissueResponseDto reissue(@Auth AuthInfo authInfo) { + return authService.reissue(authInfo.getEmail(), authInfo.getToken()); + } + +} diff --git a/src/main/java/com/kusitms/jipbap/auth/AuthExceptionHandler.java b/src/main/java/com/kusitms/jipbap/auth/AuthExceptionHandler.java new file mode 100644 index 0000000..7f6e067 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/AuthExceptionHandler.java @@ -0,0 +1,50 @@ +package com.kusitms.jipbap.auth; + +import com.kusitms.jipbap.auth.exception.*; +import com.kusitms.jipbap.common.response.ErrorCode; +import com.kusitms.jipbap.common.response.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class AuthExceptionHandler { + @ExceptionHandler(EmailExistsException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleEmailExistsException(EmailExistsException e, HttpServletRequest request) { + log.warn("Auth-001> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage()); + return new ErrorResponse<>(ErrorCode.EMAIL_EXISTS_ERROR); + } + + @ExceptionHandler(InvalidEmailException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInvalidEmailException(InvalidEmailException e, HttpServletRequest request) { + log.warn("Auth-002> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage()); + return new ErrorResponse<>(ErrorCode.INVALID_EMAIL_ERROR); + } + + @ExceptionHandler(InvalidPasswordException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInvalidPasswordException(InvalidPasswordException e, HttpServletRequest request) { + log.warn("Auth-003> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage()); + return new ErrorResponse<>(ErrorCode.INVALID_PASSWORD_ERROR); + } + + @ExceptionHandler(RefreshTokenNotFoundException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleRefreshTokenNotFoundException(RefreshTokenNotFoundException e, HttpServletRequest request) { + log.warn("Auth-004> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage()); + return new ErrorResponse<>(ErrorCode.INVALID_REFRESH_TOKEN_ERROR); + } + + @ExceptionHandler(JsonException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleJsonException(JsonException e, HttpServletRequest request) { + log.warn("Auth-005> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage()); + return new ErrorResponse<>(ErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/AuthService.java b/src/main/java/com/kusitms/jipbap/auth/AuthService.java new file mode 100644 index 0000000..5b631d0 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/AuthService.java @@ -0,0 +1,185 @@ +package com.kusitms.jipbap.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kusitms.jipbap.auth.dto.KakaoProfileDto; +import com.kusitms.jipbap.auth.dto.ReissueResponseDto; +import com.kusitms.jipbap.auth.dto.SignInResponseDto; +import com.kusitms.jipbap.auth.dto.SignUpRequestDto; +import com.kusitms.jipbap.auth.exception.*; +import com.kusitms.jipbap.security.jwt.JwtTokenProvider; +import com.kusitms.jipbap.security.jwt.TokenInfo; +import com.kusitms.jipbap.user.Role; +import com.kusitms.jipbap.user.User; +import com.kusitms.jipbap.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.lang.reflect.Member; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider tokenProvider; + + @Value("${secret.pwd}") + private String KAKAO_SECRET_SERVER_PWD; + private final String INAPP = "IN_APP"; + private final String KAKAO = "KAKAO"; + + /** + * 회원 가입 + * @param dto + */ + @Transactional + public void signUp(SignUpRequestDto dto) { + + if(userRepository.existsByEmail(dto.getEmail())) throw new EmailExistsException("이미 가입한 이메일입니다."); + userRepository.save( + User.builder() + .id(null) + .email(dto.getEmail()) + .password(passwordEncoder.encode(dto.getPassword())) + .username(dto.getUsername()) + .address(dto.getAddress()) + .phoneNum(dto.getPhoneNum()) + .role(dto.getRole()) + .refreshToken(null) + .oauth(INAPP) + .build() + ); + + } + + /** + * 로그인 + * @param email + * @param password + * @return + */ + @Transactional + public SignInResponseDto signIn(String email, String password) { + User user = userRepository.findByEmail(email).orElseThrow(()->new InvalidEmailException("회원정보가 존재하지 않습니다.")); + if(!passwordEncoder.matches(password, user.getPassword())) { + if(user.getOauth().equals("KAKAO")) { + throw new InvalidPasswordException("카카오 계정입니다. 카카오 로그인으로 시도해보세요."); + } + throw new InvalidPasswordException("잘못된 비밀번호입니다."); + } + + TokenInfo accessToken = tokenProvider.createAccessToken(user.getEmail(), user.getRole()); + TokenInfo refreshToken = tokenProvider.createRefreshToken(user.getEmail(), user.getRole()); + user.updateRefreshToken(refreshToken.getToken()); + return new SignInResponseDto( + user.getId(), user.getEmail(), user.getImage(), user.getRole(), accessToken.getToken(), refreshToken.getToken(), accessToken.getExpireTime(), refreshToken.getExpireTime() + ); + + } + + /** + * 카카오 api 사용하여 유저 정보 받기 + * @param token + * @return + */ + @Transactional + public KakaoProfileDto getKakaoProfile(String token) { + RestTemplate rt = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer "+token); + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + HttpEntity> kakaoProfileRequest = new HttpEntity<>(headers); + ResponseEntity response = rt.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.POST, + kakaoProfileRequest, + String.class + ); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + KakaoProfileDto profile; + + try { + profile = objectMapper.readValue(response.getBody(), KakaoProfileDto.class); + } catch(JsonMappingException e) { + throw new JsonException("Json Mapping 오류가 발생했습니다."); + } catch(JsonProcessingException e) { + throw new JsonException("Json Processing 오류가 발생했습니다."); + } + + return profile; + } + + /** + * 카카오 회원가입, 로그인 + * @param profile + * @return + */ + @Transactional + public SignInResponseDto kakaoAutoSignIn(KakaoProfileDto profile) { + User kakaoUser = User.builder() + .email(profile.getKakao_account().getEmail()) + .username(profile.getProperties().getNickname()) + .image(profile.getKakao_account().getProfile().getProfile_image_url()) + .password(KAKAO_SECRET_SERVER_PWD) + .oauth(KAKAO) + .build(); + + User findUser = userRepository.findByEmail(kakaoUser.getEmail()).orElse(null); + + if(findUser != null) { + log.info(profile.getKakao_account().getEmail()+": 기존 회원이 아니므로 자동 회원가입 후 로그인을 진행합니다."); + signUp(new SignUpRequestDto( + kakaoUser.getEmail(), + kakaoUser.getPassword(), + kakaoUser.getUsername(), + kakaoUser.getAddress(), + kakaoUser.getPhoneNum(), + Role.USER, + kakaoUser.getImage() + )); + findUser.updateOAuth(KAKAO); + } else { + log.info(profile.getKakao_account().getEmail()+": 기존 회원이므로 자동 로그인을 진행합니다."); + } + return signIn(kakaoUser.getEmail(), kakaoUser.getPassword()); + } + + /** + * refreshtoken 갱 + * @param email + * @param refreshToken + * @return + */ + @Transactional + public ReissueResponseDto reissue(String email, String refreshToken) { + User user = userRepository.findByEmail(email).orElseThrow(()->new InvalidEmailException("회원정보가 존재하지 않습니다.")); + if(!user.getRefreshToken().equals(refreshToken)) { + throw new RefreshTokenNotFoundException("리프레쉬 토큰에서 유저정보를 찾을 수 없습니다."); + } + tokenProvider.validateToken(refreshToken); + + TokenInfo newAccessToken = tokenProvider.createAccessToken(user.getEmail(), user.getRole()); + TokenInfo newRefreshToken = tokenProvider.createRefreshToken(user.getEmail(), user.getRole()); + return new ReissueResponseDto( + newAccessToken.getToken(), newRefreshToken.getToken() + ); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/KakaoProfileDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/KakaoProfileDto.java new file mode 100644 index 0000000..38097a6 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/KakaoProfileDto.java @@ -0,0 +1,32 @@ +package com.kusitms.jipbap.auth.dto; + +import lombok.Data; + +@Data +public class KakaoProfileDto { + + private Long id; + private Properties properties; + private KakaoAccount kakao_account; + private String connected_at; + + @Data + public class Properties { + private String nickname; + } + + @Data + public class KakaoAccount { + private Boolean has_email; + private Boolean email_needs_agreement; + private Boolean is_email_valid; + private Boolean is_email_verified; + private String email; + private Profile profile; + + @Data + public class Profile { + private String profile_image_url; + } + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/KakaoSignInRequestDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/KakaoSignInRequestDto.java new file mode 100644 index 0000000..b5b61f5 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/KakaoSignInRequestDto.java @@ -0,0 +1,15 @@ +package com.kusitms.jipbap.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class KakaoSignInRequestDto { + @NotBlank + private String token; +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/ReissueResponseDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/ReissueResponseDto.java new file mode 100644 index 0000000..c888013 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/ReissueResponseDto.java @@ -0,0 +1,13 @@ +package com.kusitms.jipbap.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReissueResponseDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/SignInRequestDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/SignInRequestDto.java new file mode 100644 index 0000000..d111776 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/SignInRequestDto.java @@ -0,0 +1,19 @@ +package com.kusitms.jipbap.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SignInRequestDto { + @Email + @NotBlank + private String email; + @NotBlank + private String password; +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/SignInResponseDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/SignInResponseDto.java new file mode 100644 index 0000000..67de147 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/SignInResponseDto.java @@ -0,0 +1,20 @@ +package com.kusitms.jipbap.auth.dto; + +import com.kusitms.jipbap.user.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SignInResponseDto { + private Long id; + private String email; + private String image; + private Role role; + private String accessToken; + private String refreshToken; + private Long accessTokenRemainTime; + private Long refreshTokenRemainTime; +} diff --git a/src/main/java/com/kusitms/jipbap/auth/dto/SignUpRequestDto.java b/src/main/java/com/kusitms/jipbap/auth/dto/SignUpRequestDto.java new file mode 100644 index 0000000..c58bf8c --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/dto/SignUpRequestDto.java @@ -0,0 +1,31 @@ +package com.kusitms.jipbap.auth.dto; + +import com.kusitms.jipbap.user.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignUpRequestDto { + @Email + @NotBlank + private String email; + @NotBlank + private String password; + @NotBlank + private String username; + @NotBlank + private String address; + @NotBlank + private String phoneNum; + @NotBlank + private Role role; + + private String imageUrl; + +} diff --git a/src/main/java/com/kusitms/jipbap/auth/exception/EmailExistsException.java b/src/main/java/com/kusitms/jipbap/auth/exception/EmailExistsException.java new file mode 100644 index 0000000..6af3d36 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/exception/EmailExistsException.java @@ -0,0 +1,7 @@ +package com.kusitms.jipbap.auth.exception; + +public class EmailExistsException extends RuntimeException{ + public EmailExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/exception/InvalidEmailException.java b/src/main/java/com/kusitms/jipbap/auth/exception/InvalidEmailException.java new file mode 100644 index 0000000..6c7dfcf --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/exception/InvalidEmailException.java @@ -0,0 +1,7 @@ +package com.kusitms.jipbap.auth.exception; + +public class InvalidEmailException extends RuntimeException{ + public InvalidEmailException(String message) { + super(message); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/exception/InvalidPasswordException.java b/src/main/java/com/kusitms/jipbap/auth/exception/InvalidPasswordException.java new file mode 100644 index 0000000..6134294 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/exception/InvalidPasswordException.java @@ -0,0 +1,7 @@ +package com.kusitms.jipbap.auth.exception; + +public class InvalidPasswordException extends RuntimeException{ + public InvalidPasswordException(String message) { + super(message); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/exception/JsonException.java b/src/main/java/com/kusitms/jipbap/auth/exception/JsonException.java new file mode 100644 index 0000000..010a549 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/exception/JsonException.java @@ -0,0 +1,7 @@ +package com.kusitms.jipbap.auth.exception; + +public class JsonException extends RuntimeException{ + public JsonException(String message) { + super(message); + } +} diff --git a/src/main/java/com/kusitms/jipbap/auth/exception/RefreshTokenNotFoundException.java b/src/main/java/com/kusitms/jipbap/auth/exception/RefreshTokenNotFoundException.java new file mode 100644 index 0000000..922dcde --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/auth/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,7 @@ +package com.kusitms.jipbap.auth.exception; + +public class RefreshTokenNotFoundException extends RuntimeException{ + public RefreshTokenNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/kusitms/jipbap/common/entity/DateEntity.java b/src/main/java/com/kusitms/jipbap/common/entity/DateEntity.java new file mode 100644 index 0000000..c04e9a0 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/common/entity/DateEntity.java @@ -0,0 +1,24 @@ +package com.kusitms.jipbap.common.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public class DateEntity { + @CreatedDate + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @LastModifiedDate + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/kusitms/jipbap/common/response/ErrorCode.java b/src/main/java/com/kusitms/jipbap/common/response/ErrorCode.java new file mode 100644 index 0000000..40aeaf8 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/common/response/ErrorCode.java @@ -0,0 +1,32 @@ +package com.kusitms.jipbap.common.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + INTERNAL_SERVER_ERROR(false,HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부에서 문제가 발생했습니다."), + NOT_FOUND(false, HttpStatus.NOT_FOUND.value(), "해당 로그인 정보는 존재하지 않습니다."), + UNAUTHORIZED(false, HttpStatus.UNAUTHORIZED.value(), "권한이 없습니다."), + + //auth + EMAIL_EXISTS_ERROR(false, HttpStatus.BAD_REQUEST.value(), "이미 존재하는 이메일입니다."), + INVALID_EMAIL_ERROR(false, HttpStatus.BAD_REQUEST.value(), "존재하지 않는 이메일 정보입니다."), + INVALID_PASSWORD_ERROR(false, HttpStatus.BAD_REQUEST.value(), "비밀번호를 확인해주세요. 카카오 계정이라면 카카오 로그인으로 시도해주세요."), + INVALID_ACCESS_TOKEN_ERROR(false, HttpStatus.BAD_REQUEST.value(), "AccessToken 정보를 찾을 수 없습니다."), + INVALID_REFRESH_TOKEN_ERROR(false, HttpStatus.BAD_REQUEST.value(), "RefreshToken 정보를 찾을 수 없습니다."), + + //user + USER_NOT_EXISTS_ERROR(false, HttpStatus.BAD_REQUEST.value(), "존재하지 않는 유저입니다."), + ; + + private Boolean isSuccess; + private int code; + private String message; + + ErrorCode(Boolean isSuccess, int code, String message) { + this.isSuccess = isSuccess; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/kusitms/jipbap/common/response/ErrorResponse.java b/src/main/java/com/kusitms/jipbap/common/response/ErrorResponse.java new file mode 100644 index 0000000..6672f21 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/common/response/ErrorResponse.java @@ -0,0 +1,29 @@ +package com.kusitms.jipbap.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ErrorResponse { + private Boolean isSuccess; + private int code; + private String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + // 오류 발생 + public ErrorResponse(ErrorCode errorCode) { + this.isSuccess = errorCode.getIsSuccess(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + } + + public ErrorResponse(ErrorCode errorCode, T result) { + this.isSuccess = errorCode.getIsSuccess(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.result = result; + } +} diff --git a/src/main/java/com/kusitms/jipbap/config/SpringSecurityConfig.java b/src/main/java/com/kusitms/jipbap/config/SpringSecurityConfig.java new file mode 100644 index 0000000..6b527cf --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/config/SpringSecurityConfig.java @@ -0,0 +1,75 @@ +package com.kusitms.jipbap.config; + +import com.kusitms.jipbap.security.jwt.ExceptionHandleFilter; +import com.kusitms.jipbap.security.jwt.JwtAuthenticationFilter; +import com.kusitms.jipbap.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +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.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SpringSecurityConfig { + + private final JwtTokenProvider tokenProvider; + private final ExceptionHandleFilter exceptionFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .httpBasic(HttpBasicConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .sessionManagement( + (sessionManagement) -> { + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS); + } + ) + //인가(Authorize) + .authorizeHttpRequests(authorize -> + authorize + .requestMatchers( + "/auth/**" + ).permitAll() //로그인, 회원가입, + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() //cors + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(exceptionFilter, JwtAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + //인증(Authentication) + return web -> web.ignoring().requestMatchers( + "/v3/api-docs/**", + "/favicon.ico", + "/swagger-resources/**", + "/swagger-ui/**", + "/swagger/**", + "/error", + "/auth/**", + "/" + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/kusitms/jipbap/security/Auth.java b/src/main/java/com/kusitms/jipbap/security/Auth.java new file mode 100644 index 0000000..4c0fbbf --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/Auth.java @@ -0,0 +1,12 @@ +package com.kusitms.jipbap.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Auth { +} + diff --git a/src/main/java/com/kusitms/jipbap/security/AuthArgumentResolver.java b/src/main/java/com/kusitms/jipbap/security/AuthArgumentResolver.java new file mode 100644 index 0000000..ac387d0 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/AuthArgumentResolver.java @@ -0,0 +1,47 @@ +package com.kusitms.jipbap.security; + +import com.kusitms.jipbap.user.Role; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +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 java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final String AUTHORIZATION_HEADER = "Authorization"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType() == AuthInfo.class; + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + UserDetails authentication = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return new AuthInfo( + webRequest.getHeader(AUTHORIZATION_HEADER), + authentication.getUsername(), + getRoles(authentication.getAuthorities()) + ); + } + + private List getRoles(Collection authorities) { + List roles = new ArrayList<>(); + for(GrantedAuthority role: authorities) { + roles.add(Role.valueOf(role.getAuthority())); + } + return roles; + } +} diff --git a/src/main/java/com/kusitms/jipbap/security/AuthInfo.java b/src/main/java/com/kusitms/jipbap/security/AuthInfo.java new file mode 100644 index 0000000..aa74874 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/AuthInfo.java @@ -0,0 +1,17 @@ +package com.kusitms.jipbap.security; + +import com.kusitms.jipbap.user.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +@Data +public class AuthInfo { + private String token; + private String email; + private List role; +} diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/ExceptionHandleFilter.java b/src/main/java/com/kusitms/jipbap/security/jwt/ExceptionHandleFilter.java new file mode 100644 index 0000000..4ea942b --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/ExceptionHandleFilter.java @@ -0,0 +1,72 @@ +package com.kusitms.jipbap.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kusitms.jipbap.common.response.ErrorCode; +import com.kusitms.jipbap.common.response.ErrorResponse; +import com.kusitms.jipbap.security.jwt.exception.EmptyTokenException; +import com.kusitms.jipbap.security.jwt.exception.InvalidTokenException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ExceptionHandleFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + try { + filterChain.doFilter(request, response); + } catch (InvalidTokenException exception) { + setErrorResponse( + HttpStatus.BAD_REQUEST, + ErrorCode.INVALID_ACCESS_TOKEN_ERROR, + request, response, exception, "TOKEN-ERROR-01" + ); + + } catch (EmptyTokenException exception) { + setErrorResponse( + HttpStatus.BAD_REQUEST, + ErrorCode.INVALID_ACCESS_TOKEN_ERROR, + request, response, exception, "TOKEN-ERROR-02" + ); + } catch (Exception exception) { + setErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + ErrorCode.INTERNAL_SERVER_ERROR, + request, response, exception, "DEFAULT-ERROR-01" + ); + } + } + + private void setErrorResponse(HttpStatus status, + ErrorCode code, + HttpServletRequest request, + HttpServletResponse response, + Exception exception, + String errorCode) { + response.setStatus(status.value()); + response.setContentType("application/json"); + ErrorResponse errorResponse = new ErrorResponse<>(code, exception.getMessage()); + try { + log.error("에러코드 {}: {} [에러 종류: {}, 요청 url: {}]", + errorCode, + exception.getMessage(), + exception.getClass(), + request.getRequestURI()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/kusitms/jipbap/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b51e8d6 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,38 @@ +package com.kusitms.jipbap.security.jwt; + +import com.kusitms.jipbap.security.jwt.exception.EmptyTokenException; +import com.kusitms.jipbap.security.jwt.exception.InvalidTokenException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (bearerToken==null) { + throw new EmptyTokenException("Authorization 헤더에 토큰이 없습니다."); + } + String jwtToken = jwtTokenProvider.resolveToken(bearerToken); + if(jwtToken != null && jwtTokenProvider.validateToken(jwtToken)) { + Authentication authentication = jwtTokenProvider.getAuthentication(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + else { + throw new InvalidTokenException("토큰이 유효하지 않습니다."); + } + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/JwtTokenProvider.java b/src/main/java/com/kusitms/jipbap/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..2f90916 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/JwtTokenProvider.java @@ -0,0 +1,111 @@ +package com.kusitms.jipbap.security.jwt; + +import com.kusitms.jipbap.security.jwt.exception.InvalidTokenException; +import com.kusitms.jipbap.user.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.naming.AuthenticationException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtTokenProvider { + + private final long ACCESS_TOKEN_VALID_TIME = (1000*60*60*24); //day + private final long REFRESH_TOKEN_VALID_TIME = (1000*60*60*24*7); //week + private final String BEARER_TYPE = "Bearer "; + + @Value("${secret.jwt}") + private String baseSecretKey; + + public TokenInfo createAccessToken(String email, Role role) { + return createToken(email, role, ACCESS_TOKEN_VALID_TIME); + } + + public TokenInfo createRefreshToken(String email, Role role) { + return createToken(email, role, REFRESH_TOKEN_VALID_TIME); + } + + public Boolean validateToken(String token) { + SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(baseSecretKey)); + try { + Jwts.parserBuilder() + .setSigningKey(secretKey).build() + .parseClaimsJws(token); + return true; + } catch (SecurityException e) { + throw new InvalidTokenException("Jwt Security error"); + } catch (MalformedJwtException e) { + throw new InvalidTokenException("MalformedJwtException - 잘못된 Jwt Token"); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException("ExpiredJwtException - 만료된 Jwt Token"); + } catch (UnsupportedJwtException e) { + throw new InvalidTokenException("UnsupportedJwtException - 지원하지 않는 Jwt Token"); + } catch (IllegalArgumentException e) { + throw new InvalidTokenException("IllegalArgumentException - 잘못된 헤더"); + } catch (io.jsonwebtoken.security.SignatureException e) { + throw new InvalidTokenException("SignatureException - 잘못된 Token 형식"); + } + } + + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + try { + claims.get("email"); + } catch(Exception e) { + try { + throw new AuthenticationException("Jwt 토큰에 이메일이 존재하지 않습니다."); + } catch (AuthenticationException ex) { + ex.printStackTrace(); + } + } + Collection authorities = + Arrays.stream(claims.get("ROLE_").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + UserDetails userDetails = new User(claims.get("email").toString(), "", authorities); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + public String resolveToken(String token) { + if(token.startsWith("Bearer ")) { + return token.replace("Bearer ", ""); + } + return null; + } + + public Claims parseClaims(String token) { + SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(baseSecretKey)); + return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody(); + } + + private TokenInfo createToken(String email, Role role, long validTime) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + validTime); + SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(baseSecretKey)); + + String token = Jwts.builder() + .claim("email", email) + .claim("ROLE_", role) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + + token = BEARER_TYPE + token; + return new TokenInfo(token, expiration.getTime() - now.getTime()); + } +} diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/TokenInfo.java b/src/main/java/com/kusitms/jipbap/security/jwt/TokenInfo.java new file mode 100644 index 0000000..8c965f0 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/TokenInfo.java @@ -0,0 +1,15 @@ +package com.kusitms.jipbap.security.jwt; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@RequiredArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class TokenInfo { + private String token; + private Long expireTime; +} diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/exception/EmptyTokenException.java b/src/main/java/com/kusitms/jipbap/security/jwt/exception/EmptyTokenException.java new file mode 100644 index 0000000..67d07fb --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/exception/EmptyTokenException.java @@ -0,0 +1,10 @@ +package com.kusitms.jipbap.security.jwt.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class EmptyTokenException extends RuntimeException{ + private String message; +} diff --git a/src/main/java/com/kusitms/jipbap/security/jwt/exception/InvalidTokenException.java b/src/main/java/com/kusitms/jipbap/security/jwt/exception/InvalidTokenException.java new file mode 100644 index 0000000..3712baa --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/security/jwt/exception/InvalidTokenException.java @@ -0,0 +1,10 @@ +package com.kusitms.jipbap.security.jwt.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class InvalidTokenException extends RuntimeException{ + private String message; +} diff --git a/src/main/java/com/kusitms/jipbap/user/Role.java b/src/main/java/com/kusitms/jipbap/user/Role.java new file mode 100644 index 0000000..9572904 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/user/Role.java @@ -0,0 +1,5 @@ +package com.kusitms.jipbap.user; + +public enum Role { + USER, ADMIN +} diff --git a/src/main/java/com/kusitms/jipbap/user/User.java b/src/main/java/com/kusitms/jipbap/user/User.java new file mode 100644 index 0000000..fb28c6c --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/user/User.java @@ -0,0 +1,54 @@ +package com.kusitms.jipbap.user; + +import com.kusitms.jipbap.common.entity.DateEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +@Entity +@Table(name = "tb_user") +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class User extends DateEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name ="user_id") + private Long id; //고유 pk + + @NotBlank + @Column(unique = true) + private String email; //로그인 email + + @NotBlank + private String password; //비밀번호 + + @NotBlank + private String username; //닉네임 + + @NotBlank + private String address; //주소 + + private String image; //프로필 사진 + + private String phoneNum; + + @Enumerated(EnumType.STRING) + private Role role; //USER, ADMIN + + private String refreshToken; + private String oauth; //INAPP, KAKAO + + public void updateRefreshToken(String newRefreshToken) { + this.refreshToken = newRefreshToken; + } + public void updateOAuth(String oauth) { + this.oauth = oauth; + } +} \ No newline at end of file diff --git a/src/main/java/com/kusitms/jipbap/user/UserController.java b/src/main/java/com/kusitms/jipbap/user/UserController.java new file mode 100644 index 0000000..949d8e0 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/user/UserController.java @@ -0,0 +1,24 @@ +package com.kusitms.jipbap.user; + +import com.kusitms.jipbap.security.Auth; +import com.kusitms.jipbap.security.AuthInfo; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @Operation(summary = "로그아웃 - 리프레쉬 토큰 삭제") + @PostMapping("/logout") + public void logout(@Auth AuthInfo authInfo) { + userService.logout(authInfo.getEmail()); + } +} + diff --git a/src/main/java/com/kusitms/jipbap/user/UserRepository.java b/src/main/java/com/kusitms/jipbap/user/UserRepository.java new file mode 100644 index 0000000..8291b94 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.kusitms.jipbap.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + Optional findByEmail(String email); +} diff --git a/src/main/java/com/kusitms/jipbap/user/UserService.java b/src/main/java/com/kusitms/jipbap/user/UserService.java new file mode 100644 index 0000000..7a5c4f2 --- /dev/null +++ b/src/main/java/com/kusitms/jipbap/user/UserService.java @@ -0,0 +1,23 @@ +package com.kusitms.jipbap.user; + +import com.kusitms.jipbap.auth.exception.InvalidEmailException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UserService { + private final UserRepository userRepository; + + /** + * 로그아웃 - User의 RefreshToken 제거 + * @param email + */ + @Transactional + public void logout(String email) { + User user = userRepository.findByEmail(email).orElseThrow(()->new InvalidEmailException("회원정보가 존재하지 않습니다.")); + user.updateRefreshToken(null); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 73f95f9..cbddd6e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,4 +30,8 @@ spring: format_sql: true jackson: serialization: - fail-on-empty-beans: false \ No newline at end of file + fail-on-empty-beans: false + +secret: + pwd: ${SECRET_PWD} + jwt: ${SECRET_JWT} \ No newline at end of file