diff --git a/batch-quartz/pom.xml b/batch-quartz/pom.xml index 176879a..c0d2354 100644 --- a/batch-quartz/pom.xml +++ b/batch-quartz/pom.xml @@ -65,6 +65,10 @@ org.springframework.boot spring-boot-starter-thymeleaf + + nz.net.ultraq.thymeleaf + thymeleaf-layout-dialect + io.jsonwebtoken jjwt-api diff --git a/batch-quartz/src/main/java/com/spring/common/advice/ApiResponse.java b/batch-quartz/src/main/java/com/spring/common/advice/ApiResponse.java new file mode 100644 index 0000000..e21d934 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/advice/ApiResponse.java @@ -0,0 +1,25 @@ +package com.spring.common.advice; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ApiResponse { + + private final LocalDateTime timestamp = LocalDateTime.now(); + private int statusCode; + private String path; + private T data; + private String message; + + @Builder + private ApiResponse(int statusCode, String path, T data, String message) { + this.statusCode = statusCode; + this.path = path; + this.data = data; + this.message = message; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/advice/ResponseWrapper.java b/batch-quartz/src/main/java/com/spring/common/advice/ResponseWrapper.java new file mode 100644 index 0000000..32c4cc3 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/advice/ResponseWrapper.java @@ -0,0 +1,65 @@ +package com.spring.common.advice; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import com.spring.common.converter.CommHttpMessageConverter; +import com.spring.common.error.ErrorResponse; +import com.spring.common.error.ErrorRule; +import com.spring.common.error.GlobalExceptionHandler; +import com.spring.infra.security.error.SecurityExceptionHandler; + +@RestControllerAdvice( + basePackages = "com.spring.domain.*.api", + basePackageClasses = { GlobalExceptionHandler.class, SecurityExceptionHandler.class } +) +public class ResponseWrapper implements ResponseBodyAdvice { + + @Override + public boolean supports( + @NonNull MethodParameter returnType, + @NonNull Class> converterType + ) { + return CommHttpMessageConverter.class.isAssignableFrom(converterType); + } + + @Override + public Object beforeBodyWrite( + @Nullable Object body, + @NonNull MethodParameter returnType, + @NonNull MediaType selectedContentType, + @NonNull Class> selectedConverterType, + @NonNull ServerHttpRequest request, + @NonNull ServerHttpResponse response + ) { + String path = request.getURI().getPath(); + + if (body instanceof ErrorResponse) { + ErrorResponse errorData = ((ErrorResponse) body); + ErrorRule errorRule = errorData.getErrorRule(); + response.setStatusCode(errorRule.getStatus()); + + return ApiResponse.builder() + .statusCode(errorRule.getStatus().value()) + .path(path) + .data(errorData.getErrors()) + .message(errorRule.getMessage()) + .build(); + } + + return ApiResponse.builder() + .statusCode(HttpStatus.OK.value()) + .path(path) + .data(body) + .build(); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/config/WebConfig.java b/batch-quartz/src/main/java/com/spring/common/config/WebConfig.java new file mode 100644 index 0000000..55a55ad --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.spring.common.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spring.common.converter.CommHttpMessageConverter; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final ObjectMapper objectMapper; + + @Override + public void extendMessageConverters(@NonNull List> converters) { + converters.add(0, new CommHttpMessageConverter(objectMapper)); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/converter/CommHttpMessageConverter.java b/batch-quartz/src/main/java/com/spring/common/converter/CommHttpMessageConverter.java new file mode 100644 index 0000000..d946527 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/converter/CommHttpMessageConverter.java @@ -0,0 +1,24 @@ +package com.spring.common.converter; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public class CommHttpMessageConverter extends MappingJackson2HttpMessageConverter { + + public CommHttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper); + objectMapper.registerModule(new JavaTimeModule()); + setObjectMapper(objectMapper); + } + + @Override + public boolean canWrite(@NonNull Class clazz, @Nullable MediaType mediaType) { + return canWrite(mediaType); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java new file mode 100644 index 0000000..bf1d28d --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java @@ -0,0 +1,20 @@ +package com.spring.common.error; + +import lombok.Getter; + +@Getter +public class BizBaseException extends RuntimeException { + + private final ExceptionRule exceptionRule; + + public BizBaseException() { + super(ExceptionRule.BAD_REQUEST.getMessage()); + this.exceptionRule = ExceptionRule.BAD_REQUEST; + } + + public BizBaseException(ExceptionRule exceptionRule) { + super(exceptionRule.getMessage()); + this.exceptionRule = exceptionRule; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/BizErrorResponse.java b/batch-quartz/src/main/java/com/spring/common/error/BizErrorResponse.java new file mode 100644 index 0000000..c817362 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/BizErrorResponse.java @@ -0,0 +1,29 @@ +package com.spring.common.error; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.validation.FieldError; + +import lombok.Getter; + +@Getter +public class BizErrorResponse extends ErrorResponse { + + public BizErrorResponse(ExceptionRule exceptionRule, List errors) { + super(exceptionRule); + this.errors = errors; + } + + public static BizErrorResponse valueOf(ExceptionRule exceptionRule) { + return new BizErrorResponse(exceptionRule, null); + } + + public static BizErrorResponse fromFieldError(List fieldErrors) { + List errors = fieldErrors.stream() + .map(fieldError -> RejectedValue.of(fieldError.getField(), fieldError.getRejectedValue(), fieldError.getDefaultMessage())) + .collect(Collectors.toList()); + return new BizErrorResponse(ExceptionRule.UNPROCESSABLE_ENTITY, errors); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/BizException.java b/batch-quartz/src/main/java/com/spring/common/error/BizException.java deleted file mode 100644 index be89fd8..0000000 --- a/batch-quartz/src/main/java/com/spring/common/error/BizException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.spring.common.error; - -public class BizException extends RuntimeException { - - public BizException(String message) { - super(message); - } - -} diff --git a/batch-quartz/src/main/java/com/spring/common/error/ErrorResponse.java b/batch-quartz/src/main/java/com/spring/common/error/ErrorResponse.java new file mode 100644 index 0000000..2880e15 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.spring.common.error; + +import java.util.List; + +import lombok.Getter; + +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public abstract class ErrorResponse { + + protected final ErrorRule errorRule; + protected List errors; + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/ErrorRule.java b/batch-quartz/src/main/java/com/spring/common/error/ErrorRule.java new file mode 100644 index 0000000..b7800fe --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/ErrorRule.java @@ -0,0 +1,8 @@ +package com.spring.common.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorRule { + HttpStatus getStatus(); + String getMessage(); +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java b/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java new file mode 100644 index 0000000..e3e31b6 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java @@ -0,0 +1,34 @@ +package com.spring.common.error; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExceptionRule implements ErrorRule { + + BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근이 금지되었습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 자원을 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 오류가 발생했습니다."), + CONFLICT(HttpStatus.CONFLICT, "데이터 충돌이 발생했습니다."), + UNPROCESSABLE_ENTITY(HttpStatus.UNPROCESSABLE_ENTITY, "요청을 처리할 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."); + + private final HttpStatus status; + private String message; + + ExceptionRule(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public ExceptionRule message(final String message) { + this.message = message; + return this; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/GlobalErrorController.java b/batch-quartz/src/main/java/com/spring/common/error/GlobalErrorController.java new file mode 100644 index 0000000..4634660 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/GlobalErrorController.java @@ -0,0 +1,26 @@ +package com.spring.common.error; + +import javax.servlet.RequestDispatcher; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class GlobalErrorController implements ErrorController { + + @ExceptionHandler(Throwable.class) + @GetMapping("/error") + public String handleError(HttpServletRequest request, Model model) { + Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + String statusMsg = status.toString(); + HttpStatus httpStatus = HttpStatus.valueOf(Integer.valueOf(statusMsg)); + model.addAttribute("message", statusMsg + " " + httpStatus.getReasonPhrase()); + return "pages/error/error"; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java b/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..44ff9dc --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java @@ -0,0 +1,20 @@ +package com.spring.common.error; + +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizBaseException.class) + public BizErrorResponse handleCustomException(BizBaseException e) { + return BizErrorResponse.valueOf(e.getExceptionRule()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public BizErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) { + return BizErrorResponse.fromFieldError(e.getFieldErrors()); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/error/RejectedValue.java b/batch-quartz/src/main/java/com/spring/common/error/RejectedValue.java new file mode 100644 index 0000000..4b22b83 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/error/RejectedValue.java @@ -0,0 +1,18 @@ +package com.spring.common.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class RejectedValue { + + private final String field; + private final Object value; + private final String reason; + + public static RejectedValue of(String field, Object value, String reason) { + return new RejectedValue(field, value, reason); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/api/SignUpApi.java b/batch-quartz/src/main/java/com/spring/domain/user/api/SignUpApi.java new file mode 100644 index 0000000..7d54d45 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/api/SignUpApi.java @@ -0,0 +1,27 @@ +package com.spring.domain.user.api; + +import javax.validation.Valid; + +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.spring.domain.user.dto.SignUpRequest; +import com.spring.domain.user.service.SignUpService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +public class SignUpApi { + + private final SignUpService signUpService; + + @PostMapping("/sign-up") + public void signUp(@RequestBody @Valid SignUpRequest request) { + signUpService.signUp(request); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/dto/SignUpRequest.java b/batch-quartz/src/main/java/com/spring/domain/user/dto/SignUpRequest.java new file mode 100644 index 0000000..d9e14cb --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/dto/SignUpRequest.java @@ -0,0 +1,33 @@ +package com.spring.domain.user.dto; + +import javax.validation.constraints.NotBlank; + +import com.spring.domain.user.entity.AppUser; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SignUpRequest { + + @NotBlank(message = "로그인ID는 필수값 입니다.") + private String loginId; + @NotBlank(message = "비밀번호는 필수값 입니다.") + private String password; + private String userName; + + public void encodePassword(String password) { + this.password = password; + } + + public AppUser toEntity() { + return AppUser.builder() + .loginId(loginId) + .password(password) + .userName(userName) + .build(); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUser.java b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUser.java index d172c86..7a67370 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUser.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUser.java @@ -12,12 +12,16 @@ import javax.persistence.Id; import javax.persistence.OneToMany; import javax.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Table(name = "APP_USER") @Getter -public final class AppUser { +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AppUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -36,4 +40,12 @@ public final class AppUser { @OneToMany(mappedBy = "appUser", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Set appUserRoleMap; + @Builder + public AppUser(String loginId, String password, String userName, Set appUserRoleMap) { + this.loginId = loginId; + this.password = password; + this.userName = userName; + this.appUserRoleMap = appUserRoleMap; + } + } diff --git a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRole.java b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRole.java index 977e3c5..8ea2019 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRole.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRole.java @@ -12,12 +12,15 @@ import javax.persistence.Id; import javax.persistence.OneToMany; import javax.persistence.Table; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Table(name = "APP_USER_ROLE") @Getter -public final class AppUserRole { +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AppUserRole { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRoleMap.java b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRoleMap.java index 104a97b..399302c 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRoleMap.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/entity/AppUserRoleMap.java @@ -11,13 +11,16 @@ import javax.persistence.ManyToOne; import javax.persistence.MapsId; import javax.persistence.Table; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Table(name = "APP_USER_ROLE_MAP") @Getter -public final class AppUserRoleMap { +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AppUserRoleMap { @EmbeddedId private AppUserRoleMapId id; diff --git a/batch-quartz/src/main/java/com/spring/domain/user/error/UserNotFoundException.java b/batch-quartz/src/main/java/com/spring/domain/user/error/UserNotFoundException.java new file mode 100644 index 0000000..2b0b594 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/error/UserNotFoundException.java @@ -0,0 +1,16 @@ +package com.spring.domain.user.error; + +import com.spring.common.error.BizBaseException; +import com.spring.common.error.ExceptionRule; + +public class UserNotFoundException extends BizBaseException { + + public UserNotFoundException() { + super(ExceptionRule.USER_NOT_FOUND); + } + + public UserNotFoundException(ExceptionRule exceptionRule) { + super(exceptionRule); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/service/SignUpService.java b/batch-quartz/src/main/java/com/spring/domain/user/service/SignUpService.java new file mode 100644 index 0000000..f623227 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/service/SignUpService.java @@ -0,0 +1,25 @@ +package com.spring.domain.user.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.spring.domain.user.dto.SignUpRequest; +import com.spring.domain.user.repository.AppUserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SignUpService { + + private final AppUserRepository appUserRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void signUp(SignUpRequest request) { + request.encodePassword(passwordEncoder.encode(request.getPassword())); + appUserRepository.save(request.toEntity()); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/view/UserView.java b/batch-quartz/src/main/java/com/spring/domain/user/view/UserView.java deleted file mode 100644 index a341bfe..0000000 --- a/batch-quartz/src/main/java/com/spring/domain/user/view/UserView.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.spring.domain.user.view; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; - -@Controller -@RequestMapping("/user") -public class UserView { - - @GetMapping("/sign-in") - public String signin() { - return "/views/user/signIn"; - } - -} diff --git a/batch-quartz/src/main/java/com/spring/infra/security/config/SecurityConfig.java b/batch-quartz/src/main/java/com/spring/infra/security/config/SecurityConfig.java index 6450b8f..d7e87a6 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/config/SecurityConfig.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/config/SecurityConfig.java @@ -5,16 +5,18 @@ import java.util.List; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 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.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; @@ -41,10 +43,16 @@ import com.spring.infra.security.provider.UserAuthenticationProvider; */ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) public class SecurityConfig { - private static final String[] PERMITTED_URI = {"/favicon.ico", "/api/auth/**", "/user/sign-in", "/h2-console/**"}; + private static final String[] PERMITTED_URI = { + "/", + "/h2-console/**", + "/favicon.ico", + "/sign-up", + "/api/user/sign-up" + }; /** * Spring Security의 필터 체인을 구성합니다. @@ -56,6 +64,7 @@ public class SecurityConfig { * @return 구성된 SecurityFilterChain */ @Bean + @Order(1) SecurityFilterChain securityFilterChain( HttpSecurity http, JwtTokenService tokenService, @@ -73,7 +82,7 @@ public class SecurityConfig { .anyRequest().authenticated() ) .logout(logout -> logout - .logoutSuccessUrl("/user/sign-in") + .logoutSuccessUrl("/") .invalidateHttpSession(true)) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -96,12 +105,18 @@ public class SecurityConfig { /** * 특정 요청에 대해 보안 검사를 무시하도록 설정합니다. * - * @return WebSecurityCustomizer 객체 + * @return 구성된 SecurityFilterChain */ @Bean - WebSecurityCustomizer ignoringCustomizer() { - return web -> web.ignoring() - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()); + @Order(0) + SecurityFilterChain resources(HttpSecurity http) throws Exception { + return http.requestMatchers(matchers -> matchers + .requestMatchers(PathRequest.toStaticResources().atCommonLocations())) + .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) + .requestCache(RequestCacheConfigurer::disable) + .securityContext(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .build(); } /** diff --git a/batch-quartz/src/main/java/com/spring/infra/security/domain/UserPrincipal.java b/batch-quartz/src/main/java/com/spring/infra/security/domain/UserPrincipal.java index 3c04ff2..50a96ba 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/domain/UserPrincipal.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/domain/UserPrincipal.java @@ -96,7 +96,7 @@ public final class UserPrincipal implements UserDetails { */ @Override public String getPassword() { - return null; + return appUser.getPassword(); } /** diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java new file mode 100644 index 0000000..5cfe868 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java @@ -0,0 +1,22 @@ +package com.spring.infra.security.error; + +import org.springframework.security.core.AuthenticationException; + +import lombok.Getter; + +@Getter +public class SecurityAuthException extends AuthenticationException { + + private final SecurityExceptionRule exceptionRule; + + public SecurityAuthException(String msg) { + super(msg); + this.exceptionRule = null; + } + + public SecurityAuthException(SecurityExceptionRule exceptionRule) { + super(exceptionRule.getMessage()); + this.exceptionRule = exceptionRule; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityErrorResponse.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityErrorResponse.java new file mode 100644 index 0000000..d133a5a --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityErrorResponse.java @@ -0,0 +1,15 @@ +package com.spring.infra.security.error; + +import com.spring.common.error.ErrorResponse; + +public class SecurityErrorResponse extends ErrorResponse { + + protected SecurityErrorResponse(SecurityExceptionRule exceptionRule) { + super(exceptionRule); + } + + public static SecurityErrorResponse valueOf(SecurityExceptionRule securityExceptionRule) { + return new SecurityErrorResponse(securityExceptionRule); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionHandler.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionHandler.java new file mode 100644 index 0000000..da594c8 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionHandler.java @@ -0,0 +1,45 @@ +package com.spring.infra.security.error; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; + +@RestControllerAdvice +public class SecurityExceptionHandler { + + @ExceptionHandler(SecurityAuthException.class) + public SecurityErrorResponse handleAuthenticationException(SecurityAuthException e) { + return SecurityErrorResponse.valueOf(e.getExceptionRule()); + } + + @ExceptionHandler(AccessDeniedException.class) + public SecurityErrorResponse handleAccessDeniedException() { + return SecurityErrorResponse.valueOf(SecurityExceptionRule.USER_FORBIDDEN); + } + + @ExceptionHandler(AuthenticationException.class) + public SecurityErrorResponse handleAuthenticationException() { + return SecurityErrorResponse.valueOf(SecurityExceptionRule.USER_UNAUTHORIZED); + } + + @ExceptionHandler(SignatureException.class) + public SecurityErrorResponse handleSignatureException() { + return SecurityErrorResponse.valueOf(SecurityExceptionRule.SIGNATURE_ERROR); + } + + @ExceptionHandler(MalformedJwtException.class) + public SecurityErrorResponse handleMalformedJwtException() { + return SecurityErrorResponse.valueOf(SecurityExceptionRule.MALFORMED_JWT_ERROR); + } + + @ExceptionHandler(ExpiredJwtException.class) + public SecurityErrorResponse handleExpiredJwtException() { + return SecurityErrorResponse.valueOf(SecurityExceptionRule.EXPIRED_JWT_ERROR); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java new file mode 100644 index 0000000..ab56dbd --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java @@ -0,0 +1,36 @@ +package com.spring.infra.security.error; + +import org.springframework.http.HttpStatus; + +import com.spring.common.error.ErrorRule; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SecurityExceptionRule implements ErrorRule { + + UNSUPPORTED_MEDIA_ERROR(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원되지 않는 유형 입니다."), + USER_BAD_REQUEST(HttpStatus.BAD_REQUEST, "사용자 정보가 올바르지 않습니다."), + USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "사용자 인증에 실패 하였습니다."), + USER_FORBIDDEN(HttpStatus.FORBIDDEN, "사용자 권한이 없습니다."), + JWT_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 잘못되었습니다."), + SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."), + MALFORMED_JWT_ERROR(HttpStatus.UNAUTHORIZED, "올바르지 않은 토큰입니다."), + EXPIRED_JWT_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다. 다시 로그인해주세요."); + + private final HttpStatus status; + private String message; + + SecurityExceptionRule(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public SecurityExceptionRule message(final String message) { + this.message = message; + return this; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/security/filter/AuthenticationProcessingFilter.java b/batch-quartz/src/main/java/com/spring/infra/security/filter/AuthenticationProcessingFilter.java index afa3f4a..2692a33 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/filter/AuthenticationProcessingFilter.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/filter/AuthenticationProcessingFilter.java @@ -18,6 +18,8 @@ import org.springframework.util.StringUtils; import com.fasterxml.jackson.databind.ObjectMapper; import com.spring.infra.security.dto.SignInRequest; +import com.spring.infra.security.error.SecurityAuthException; +import com.spring.infra.security.error.SecurityExceptionRule; public class AuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { @@ -37,11 +39,11 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (!isValidRequestType(request)) { - throw new IllegalStateException("request is not supported. check request method and content-type"); + throw new SecurityAuthException(SecurityExceptionRule.UNSUPPORTED_MEDIA_ERROR); } var signInRequest = objectMapper.readValue(request.getReader(), SignInRequest.class); if (!isValidRequest(signInRequest)) { - throw new IllegalArgumentException("Ussername & Password are not empty!!"); + throw new SecurityAuthException(SecurityExceptionRule.USER_BAD_REQUEST); } var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword()); return this.getAuthenticationManager().authenticate(token); diff --git a/batch-quartz/src/main/java/com/spring/infra/security/filter/JwtAuthenticationFilter.java b/batch-quartz/src/main/java/com/spring/infra/security/filter/JwtAuthenticationFilter.java index 205c603..301f836 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/filter/JwtAuthenticationFilter.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/filter/JwtAuthenticationFilter.java @@ -11,6 +11,8 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import com.spring.infra.security.jwt.JwtTokenRule; @@ -29,8 +31,10 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public final class JwtAuthenticationFilter extends OncePerRequestFilter { + private final AntPathMatcher pathMatcher = new AntPathMatcher(); private final JwtTokenService jwtTokenService; private final List permitAllUrls; + private static final String EXCEPTION_ATTRIBUTE = "exception"; /** * 요청마다 실행되는 필터 메소드입니다. @@ -51,7 +55,7 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter { ) throws ServletException, IOException { String requestURI = request.getRequestURI(); - if (permitAllUrls.stream().anyMatch(requestURI::startsWith)) { + if (permitAllUrls.stream().anyMatch(url -> pathMatcher.match(url, requestURI))) { filterChain.doFilter(request, response); return; } @@ -64,16 +68,27 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter { } String refreshToken = jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.REFRESH_PREFIX); - if (jwtTokenService.validateRefreshToken(refreshToken)) { - Authentication authentication = jwtTokenService.getAuthentication(refreshToken); - String reissuedAccessToken = jwtTokenService.generateAccessToken(response, authentication); - jwtTokenService.generateRefreshToken(response, authentication); - setAuthenticationToContext(reissuedAccessToken); + if (StringUtils.hasText(refreshToken)) { + if (validateToken(refreshToken, request)) { + try { + Authentication authentication = jwtTokenService.getAuthentication(refreshToken); + String reissuedAccessToken = jwtTokenService.generateAccessToken(response, authentication); + jwtTokenService.generateRefreshToken(response, authentication); + setAuthenticationToContext(reissuedAccessToken); + } catch (Exception e) { + jwtTokenService.deleteCookie(response); + request.setAttribute(EXCEPTION_ATTRIBUTE, e); + } + filterChain.doFilter(request, response); + return; + } + + jwtTokenService.deleteCookie(response); + filterChain.doFilter(request, response); + } else { filterChain.doFilter(request, response); - return; } - jwtTokenService.deleteCookie(response); } /** @@ -81,9 +96,19 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter { * * @param token 토큰 */ - private void setAuthenticationToContext(String token) { - Authentication authentication = jwtTokenService.getAuthentication(token); + private void setAuthenticationToContext(final String token) { + Authentication authentication = jwtTokenService.getJwtAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } + private boolean validateToken(final String token, HttpServletRequest request) { + try { + jwtTokenService.validateToken(token); + return true; + } catch (Exception e) { + request.setAttribute(EXCEPTION_ATTRIBUTE, e); + return false; + } + } + } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAccessDeniedHandler.java b/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAccessDeniedHandler.java index c57dae4..a4afcc3 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAccessDeniedHandler.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAccessDeniedHandler.java @@ -6,9 +6,11 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; /** * JWT 인증에서 접근 거부 상황을 처리하는 핸들러 클래스입니다. @@ -22,6 +24,12 @@ import org.springframework.stereotype.Component; @Component public class SecurityAccessDeniedHandler implements AccessDeniedHandler { + private final HandlerExceptionResolver resolver; + + public SecurityAccessDeniedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + /** * 접근 거부 상황을 처리합니다. * @@ -37,7 +45,7 @@ public class SecurityAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - response.sendError(HttpServletResponse.SC_FORBIDDEN); + resolver.resolveException(request, response, null, accessDeniedException); } } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAuthenticationEntryPoint.java b/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAuthenticationEntryPoint.java index 5e4185e..9d0ef34 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAuthenticationEntryPoint.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/handler/SecurityAuthenticationEntryPoint.java @@ -6,38 +6,36 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; /** * JWT 인증에서 인증되지 않은 접근을 처리하는 진입점 클래스입니다. * - *

이 클래스는 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근을 시도할 때 호출됩니다. - * 이런 경우 SC_UNAUTHORIZED (401) 응답을 반환합니다.

- * * @author mindol * @version 1.0 */ @Component public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint { - /** - * 인증되지 않은 접근을 처리합니다. - * - *

인증되지 않은 사용자가 보호된 리소스에 접근을 시도할 때 호출됩니다. - * 이 메소드는 SC_UNAUTHORIZED (401) 상태 코드를 응답으로 전송합니다.

- * - * @param request 현재 HTTP 요청 - * @param response 현재 HTTP 응답 - * @param authException 발생한 인증 예외 - * @throws IOException 입출력 예외 발생 시 - * @throws ServletException 서블릿 예외 발생 시 - */ + private final HandlerExceptionResolver resolver; + private static final String EXCEPTION_ATTRIBUTE = "exception"; + + public SecurityAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + if (request.getAttribute(EXCEPTION_ATTRIBUTE) != null) { + resolver.resolveException(request, response, null, (Exception) request.getAttribute(EXCEPTION_ATTRIBUTE)); + return; + } + response.sendError(HttpServletResponse.SC_NOT_FOUND); } } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninFailureHandler.java b/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninFailureHandler.java index f724b35..740df27 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninFailureHandler.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninFailureHandler.java @@ -6,20 +6,28 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; @Component public class SigninFailureHandler implements AuthenticationFailureHandler { + private final HandlerExceptionResolver resolver; + + public SigninFailureHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + @Override public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, AuthenticationException exception ) throws IOException, ServletException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패 + resolver.resolveException(request, response, null, exception); } } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninSuccessHandler.java b/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninSuccessHandler.java index c88cb67..7a2cc53 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninSuccessHandler.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/handler/SigninSuccessHandler.java @@ -41,7 +41,7 @@ public class SigninSuccessHandler implements AuthenticationSuccessHandler { redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl()); } else { // 로그인 버튼 눌러서 로그인한 경우 기존에 있던 페이지로 리다이렉트 String prevPage = String.valueOf(request.getSession().getAttribute("prevPage")); - redirectStrategy.sendRedirect(request, response, prevPage); + redirectStrategy.sendRedirect(request, response, "null".equals(prevPage) ? "/main" : prevPage); } } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenGenerator.java b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenGenerator.java index 0cb4ae4..7b235b6 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenGenerator.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenGenerator.java @@ -1,20 +1,19 @@ package com.spring.infra.security.jwt; +import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; -import javax.crypto.spec.SecretKeySpec; - import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import lombok.RequiredArgsConstructor; /** @@ -30,6 +29,7 @@ import lombok.RequiredArgsConstructor; public class JwtTokenGenerator { private final JwtProperties jwtProperties; + private final JwtTokenUtil jwtTokenUtil; /** * 액세스 토큰을 생성합니다. @@ -42,8 +42,9 @@ public class JwtTokenGenerator { .setHeader(createHeader()) .setClaims(createClaims(authentication)) .setSubject(authentication.getName()) - .setExpiration(Date.from(Instant.now().plus(jwtProperties.getAccessToken().getExpiration(), ChronoUnit.HOURS))) - .signWith(new SecretKeySpec(jwtProperties.getAccessToken().getSecret().getBytes(), SignatureAlgorithm.HS512.getJcaName())) + .setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) + .setExpiration(Date.from(Instant.now().plus(jwtProperties.getAccessToken().getExpiration(), ChronoUnit.MINUTES))) + .signWith(jwtTokenUtil.getSigningKey(jwtProperties.getAccessToken().getSecret())) .compact(); } @@ -57,8 +58,8 @@ public class JwtTokenGenerator { return Jwts.builder() .setHeader(createHeader()) .setSubject(authentication.getName()) - .setExpiration(Date.from(Instant.now().plus(jwtProperties.getRefreshToken().getExpiration(), ChronoUnit.HOURS))) - .signWith(new SecretKeySpec(jwtProperties.getRefreshToken().getSecret().getBytes(), SignatureAlgorithm.HS512.getJcaName())) + .setExpiration(Date.from(Instant.now().plus(jwtProperties.getRefreshToken().getExpiration(), ChronoUnit.MINUTES))) + .signWith(jwtTokenUtil.getSigningKey(jwtProperties.getRefreshToken().getSecret())) .compact(); } diff --git a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenRule.java b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenRule.java index 33c382a..7af0f74 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenRule.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenRule.java @@ -28,12 +28,12 @@ public enum JwtTokenRule { /** * 액세스 토큰의 접두사입니다. */ - ACCESS_PREFIX("access"), + ACCESS_PREFIX("accessToken"), /** * 리프레시 토큰의 접두사입니다. */ - REFRESH_PREFIX("refresh"), + REFRESH_PREFIX("refreshToken"), /** * Bearer 인증 스키마의 접두사입니다. diff --git a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenService.java b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenService.java index ae43a43..d59603d 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenService.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenService.java @@ -1,6 +1,8 @@ package com.spring.infra.security.jwt; import java.security.Key; +import java.util.List; +import java.util.stream.Collectors; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; @@ -9,11 +11,14 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.spring.infra.security.service.UserPrincipalService; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; /** @@ -59,7 +64,7 @@ public class JwtTokenService { */ public String generateAccessToken(HttpServletResponse response, Authentication authentication) { String accessToken = jwtTokenGenerator.generateAccessToken(authentication); - ResponseCookie cookie = setTokenToCookie(JwtTokenRule.ACCESS_PREFIX.getValue(), accessToken, accessExpiration / 1000); + ResponseCookie cookie = setTokenToCookie(JwtTokenRule.ACCESS_PREFIX.getValue(), accessToken, accessExpiration); response.addHeader(JwtTokenRule.JWT_ISSUE_HEADER.getValue(), cookie.toString()); return accessToken; } @@ -73,7 +78,7 @@ public class JwtTokenService { */ public String generateRefreshToken(HttpServletResponse response, Authentication authentication) { String refreshToken = jwtTokenGenerator.generateRefreshToken(authentication); - ResponseCookie cookie = setTokenToCookie(JwtTokenRule.REFRESH_PREFIX.getValue(), refreshToken, refreshExpiration / 1000); + ResponseCookie cookie = setTokenToCookie(JwtTokenRule.REFRESH_PREFIX.getValue(), refreshToken, refreshExpiration); response.addHeader(JwtTokenRule.JWT_ISSUE_HEADER.getValue(), cookie.toString()); return refreshToken; } @@ -91,11 +96,20 @@ public class JwtTokenService { .path("/") .maxAge(maxAgeSeconds) .httpOnly(true) - .sameSite("None") - .secure(true) + .sameSite("Lax") + .secure(false) .build(); } + /** + * 액세스 토큰의 유효성을 검증합니다. + * + * @param token 검증할 토큰 + */ + public void validateToken(String token) { + jwtTokenUtil.tokenStatus(token, accessSecretKey); + } + /** * 액세스 토큰의 유효성을 검증합니다. * @@ -133,13 +147,32 @@ public class JwtTokenService { } /** - * 토큰으로부터 인증 정보를 생성합니다. + * JWT 토큰으로부터 인증 정보를 생성합니다. * * @param token JWT 토큰 * @return 생성된 Authentication 객체 */ + public Authentication getJwtAuthentication(String token) { + Claims claims = getUserClaims(token); + String sub = claims.getSubject(); + List auths = claims.keySet().stream() + .filter(key -> key.equals(JwtTokenRule.AUTHORITIES_KEY.getValue())) + .flatMap(key -> ((List) claims.get(key)).stream()) + .map(String::valueOf) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + return new UsernamePasswordAuthenticationToken(sub, "", auths); + } + + /** + * 토큰으로부터 인증 정보를 가져와서 DB정보를 조회한다. + * + * @param token JWT 토큰 + * @return 생성된 Authentication 객체 + */ + @Transactional(readOnly = true) public Authentication getAuthentication(String token) { - UserDetails principal = userPrincipalService.loadUserByUsername(getUserPk(token, accessSecretKey)); + UserDetails principal = userPrincipalService.loadUserByUsername(getUserPk(token)); return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities()); } @@ -147,12 +180,25 @@ public class JwtTokenService { * 토큰에서 사용자 식별자를 추출합니다. * * @param token JWT 토큰 - * @param secretKey 비밀 키 * @return 추출된 사용자 식별자 */ - private String getUserPk(String token, Key secretKey) { + private Claims getUserClaims(String token) { return Jwts.parserBuilder() - .setSigningKey(secretKey) + .setSigningKey(accessSecretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * 토큰에서 사용자 식별자를 추출합니다. + * + * @param token JWT 토큰 + * @return 추출된 사용자 식별자 + */ + private String getUserPk(String token) { + return Jwts.parserBuilder() + .setSigningKey(accessSecretKey) .build() .parseClaimsJws(token) .getBody() diff --git a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenUtil.java b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenUtil.java index 9a49a78..780566a 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenUtil.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/jwt/JwtTokenUtil.java @@ -9,10 +9,14 @@ import javax.servlet.http.Cookie; import org.springframework.stereotype.Component; +import com.spring.infra.security.error.SecurityExceptionRule; + import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; import lombok.extern.slf4j.Slf4j; /** @@ -42,14 +46,35 @@ public class JwtTokenUtil { .parseClaimsJws(token); return JwtTokenStatus.AUTHENTICATED; } catch (ExpiredJwtException | IllegalArgumentException e) { - log.error("만료된 JWT 토큰입니다."); return JwtTokenStatus.EXPIRED; } catch (JwtException e) { - log.error("JWT 토큰이 잘못되었습니다."); return JwtTokenStatus.INVALID; } } + /** + * JWT 토큰의 상태를 확인하고 예외를 던집니다. + * + * @param token 검증할 JWT 토큰 + * @param secretKey 토큰 검증에 사용할 비밀 키 + */ + public void tokenStatus(String token, Key secretKey) { + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException | IllegalArgumentException e) { + throw new ExpiredJwtException(null, null, SecurityExceptionRule.EXPIRED_JWT_ERROR.getMessage()); + } catch (SignatureException e) { + throw new SignatureException(SecurityExceptionRule.SIGNATURE_ERROR.getMessage()); + } catch (MalformedJwtException e) { + throw new MalformedJwtException(SecurityExceptionRule.MALFORMED_JWT_ERROR.getMessage()); + } catch (JwtException e) { + throw new JwtException(SecurityExceptionRule.JWT_TOKEN_ERROR.getMessage()); + } + } + /** * 쿠키에서 특정 접두사를 가진 토큰을 추출합니다. * diff --git a/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java b/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java index 20e6f42..aa3c5f2 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java @@ -8,6 +8,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.spring.infra.security.service.UserPrincipalService; @@ -20,6 +21,7 @@ public class UserAuthenticationProvider implements AuthenticationProvider { private final PasswordEncoder passwordEncoder; private final UserPrincipalService userPrincipalService; + @Transactional(readOnly = true) @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String loginId = authentication.getName(); diff --git a/batch-quartz/src/main/java/com/spring/infra/security/service/UserPrincipalService.java b/batch-quartz/src/main/java/com/spring/infra/security/service/UserPrincipalService.java index 22d6b93..55d4a3e 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/service/UserPrincipalService.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/service/UserPrincipalService.java @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import com.spring.domain.user.entity.AppUser; import com.spring.domain.user.repository.AppUserRepository; import com.spring.infra.security.domain.UserPrincipal; +import com.spring.infra.security.error.SecurityExceptionRule; import lombok.RequiredArgsConstructor; @@ -22,7 +23,7 @@ public class UserPrincipalService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { AppUser user = appUserRepository.findByLoginId(username) - .orElseThrow(() -> new UsernameNotFoundException("NOT FOUND USER")); + .orElseThrow(() -> new UsernameNotFoundException(SecurityExceptionRule.USER_UNAUTHORIZED.getMessage())); return UserPrincipal.valueOf(user); } diff --git a/batch-quartz/src/main/java/com/spring/web/controller/MainController.java b/batch-quartz/src/main/java/com/spring/web/controller/MainController.java new file mode 100644 index 0000000..c34d576 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/web/controller/MainController.java @@ -0,0 +1,18 @@ +package com.spring.web.controller; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/main") +public class MainController { + + @GetMapping + @PreAuthorize("hasRole('ROLE_ADMIN')") + public String main() { + return "pages/main/main"; + } + +} diff --git a/batch-quartz/src/main/java/com/spring/web/controller/SignController.java b/batch-quartz/src/main/java/com/spring/web/controller/SignController.java new file mode 100644 index 0000000..558e40b --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/web/controller/SignController.java @@ -0,0 +1,21 @@ +package com.spring.web.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/") +public class SignController { + + @GetMapping + public String signIn() { + return "pages/sign/sign-in"; + } + + @GetMapping("/sign-up") + public String signUp() { + return "pages/sign/sign-up"; + } + +} diff --git a/batch-quartz/src/main/resources/application.yml b/batch-quartz/src/main/resources/application.yml index 53e57ac..8057837 100644 --- a/batch-quartz/src/main/resources/application.yml +++ b/batch-quartz/src/main/resources/application.yml @@ -33,6 +33,7 @@ spring: # - classpath:quartz-schema.sql jpa: + open-in-view: false database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create @@ -77,9 +78,9 @@ spring: cache: false check-template-location: false enabled: true - prefix: classpath:/templates + prefix: classpath:/templates/ suffix: .html - view-names: /views/* + view-names: pages/* h2: console: # H2 DB를 웹에서 관리할 수 있는 기능 @@ -90,8 +91,8 @@ spring: jwt: access-token: - secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290 - expiration: 900 # 15분 + secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc= + expiration: 1 refresh-token: - secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290 - expiration: 604800 # 7일 \ No newline at end of file + secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc= + expiration: 10080 \ No newline at end of file diff --git a/batch-quartz/src/main/resources/static/css/style.css b/batch-quartz/src/main/resources/static/css/style.css index 49eb822..cd6808d 100644 --- a/batch-quartz/src/main/resources/static/css/style.css +++ b/batch-quartz/src/main/resources/static/css/style.css @@ -1,11 +1,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: linear-gradient(to right, #f0f0f5, #e9ecef); - display: flex; - justify-content: center; - align-items: center; - height: 100vh; margin: 0; + display: flex; + flex-direction: column; /* 세로 방향으로 정렬 */ + justify-content: center; /* 수직 중앙 정렬 */ + align-items: center; /* 수평 중앙 정렬 */ + height: 100vh; /* 전체 화면 높이 사용 */ } .container { @@ -15,6 +16,7 @@ body { box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); width: 350px; text-align: center; + margin-bottom: 20px; /* footer와의 간격 조정 */ } .icon { @@ -59,17 +61,6 @@ input::placeholder { color: #aaa; /* 플레이스홀더 색상 */ } -.remember-me { - display: flex; - align-items: center; - justify-content: flex-start; - margin-bottom: 15px; -} - -.remember-me label { - margin-left: 5px; -} - button { width: 100%; padding: 12px; @@ -84,4 +75,20 @@ button { button:hover { background-color: #005bb5; +} + +.small-button { + padding: 5px 10px; /* 버튼 패딩 조정 */ + font-size: 14px; /* 글자 크기 조정 */ + background-color: #007aff; /* 배경색 */ + color: white; /* 글자색 */ + border: none; /* 테두리 없음 */ + border-radius: 5px; /* 모서리 둥글게 */ + cursor: pointer; /* 커서 포인터 */ + transition: background-color 0.3s; /* 배경색 전환 효과 */ + margin-top: 10px; /* 로그인 버튼과의 간격 추가 */ +} + +.small-button:hover { + background-color: #005bb5; /* 호버 시 배경색 변경 */ } \ No newline at end of file diff --git a/batch-quartz/src/main/resources/static/js/apis/user-api.js b/batch-quartz/src/main/resources/static/js/apis/user-api.js new file mode 100644 index 0000000..9fe7a82 --- /dev/null +++ b/batch-quartz/src/main/resources/static/js/apis/user-api.js @@ -0,0 +1,13 @@ +import apiClient from '../common/axios-instance.js'; + +export const signIn = async (username, password) => { + try { + await apiClient.post('/sign-in', {username, password}); + } catch (error) { + console.error('Sign-in error:', error); + } +}; + +export const signUp = async (loginId, password, userName) => { + await apiClient.post('/api/user/sign-up', {loginId, password, userName}); +}; \ No newline at end of file diff --git a/batch-quartz/src/main/resources/static/js/common/axios-instance.js b/batch-quartz/src/main/resources/static/js/common/axios-instance.js new file mode 100644 index 0000000..c442081 --- /dev/null +++ b/batch-quartz/src/main/resources/static/js/common/axios-instance.js @@ -0,0 +1,87 @@ +// Axios apiClient 생성 +const apiClient = axios.create({ + baseURL: 'http://localhost:8081', // 기본 URL 설정 + timeout: 100000000, // 요청 타임아웃 설정 (10초) + headers: { + 'Content-Type': 'application/json', + } +}); + +// 요청 인터셉터 추가 +apiClient.interceptors.request.use( + (config) => { + const token = getAccessToken(); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// 응답 인터셉터 추가 +apiClient.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + const originalRequest = error.config; + + // 401 Unauthorized 에러 처리 + if (error.response && error.response.status === 401 && !originalRequest._retry) { + console.log("333333333333"); + originalRequest._retry = true; + + const refreshToken = getRefreshToken(); + console.log("refreshToken===="+refreshToken); + if (refreshToken) { + try { + const response = await axios.post(baseURL+'/auth/refresh', { + refreshToken: refreshToken, + }); + + const { accessToken, newRefreshToken } = response.data; + + // 새로 받은 토큰을 쿠키에 저장 + setAccessToken(accessToken); + if (newRefreshToken) { + setRefreshToken(newRefreshToken); + } + + // 갱신된 토큰으로 원래 요청 재시도 + originalRequest.headers['Authorization'] = `Bearer ${accessToken}`; + return apiClient(originalRequest); + } catch (refreshError) { + // refresh token이 만료된 경우 처리 (로그아웃 등) + console.error('Token refresh failed:', refreshError); + // 로그아웃 처리 또는 로그인 페이지로 리다이렉트 + } + } + } + return Promise.reject(error); + } +); + +// 쿠키에서 accessToken 가져오기 +function getAccessToken() { + return Cookies.get('accessToken'); +} + +// 쿠키에서 refreshToken 가져오기 +function getRefreshToken() { + return Cookies.get('refreshToken'); +} + +// 쿠키에 accessToken 저장 +function setAccessToken(token) { + Cookies.set('accessToken', token, { secure: true, sameSite: 'strict' }); +} + +// 쿠키에 refreshToken 저장 +function setRefreshToken(token) { + Cookies.set('refreshToken', token, { secure: true, sameSite: 'strict' }); +} + +export default apiClient; \ No newline at end of file diff --git a/batch-quartz/src/main/resources/static/js/common/axiosInstance.js b/batch-quartz/src/main/resources/static/js/common/axiosInstance.js deleted file mode 100644 index e2352b8..0000000 --- a/batch-quartz/src/main/resources/static/js/common/axiosInstance.js +++ /dev/null @@ -1,43 +0,0 @@ -// Axios 인스턴스 생성 -const axiosInstance = axios.create({ - baseURL: 'http://localhost:8081', // 기본 URL 설정 - timeout: 10000, // 요청 타임아웃 설정 (10초) - headers: { - 'Content-Type': 'application/json', - // 필요한 경우 추가 헤더 설정 - } -}); - -// 요청 인터셉터 (필요한 경우) -axiosInstance.interceptors.request.use( - config => { - // 요청 전에 수행할 작업 (예: 토큰 추가) - const token = localStorage.getItem('token'); // 예시: 로컬 스토리지에서 토큰 가져오기 - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; - } - return config; - }, - error => { - return Promise.reject(error); - } -); - -// 응답 인터셉터 (필요한 경우) -axiosInstance.interceptors.response.use( - response => { - return response; - }, - error => { - console.log(error.response); - // 오류 처리 (예: 401 Unauthorized 처리) - if (error.response && error.response.status === 401) { - // 로그아웃 처리 또는 리다이렉트 - console.log("111111111111111"); - console.log(error.response); - } - return Promise.reject(error); - } -); - -export default axiosInstance; \ No newline at end of file diff --git a/batch-quartz/src/main/resources/static/js/lib/cookie/js.cookie.min.js b/batch-quartz/src/main/resources/static/js/lib/cookie/js.cookie.min.js new file mode 100644 index 0000000..53d35a3 --- /dev/null +++ b/batch-quartz/src/main/resources/static/js/lib/cookie/js.cookie.min.js @@ -0,0 +1,2 @@ +/*! js-cookie v3.0.5 | MIT */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t { - try { - const response = await axiosInstance.post('/sign-in', { - username, - password - }); - console.log('로그인 성공:', response.data); - } catch (error) { - console.error('로그인 실패:', error); - } -}; - -document.getElementById('signinForm').addEventListener('submit', function(event) { - event.preventDefault(); // 기본 폼 제출 방지 - const username = document.getElementById('username').value; - const password = document.getElementById('password').value; - login(username, password); -}); \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/fragments/config.html b/batch-quartz/src/main/resources/templates/fragments/config.html new file mode 100644 index 0000000..2e4bad7 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/fragments/config.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/fragments/footer.html b/batch-quartz/src/main/resources/templates/fragments/footer.html new file mode 100644 index 0000000..0c7f257 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/fragments/footer.html @@ -0,0 +1,6 @@ + + +
+

Footer

+
+ \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/fragments/header.html b/batch-quartz/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..94a47b7 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/fragments/header.html @@ -0,0 +1,4 @@ + + +

사이트 로고

+ \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/fragments/left.html b/batch-quartz/src/main/resources/templates/fragments/left.html new file mode 100644 index 0000000..c193066 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/fragments/left.html @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/layouts/layout.html b/batch-quartz/src/main/resources/templates/layouts/layout.html new file mode 100644 index 0000000..a175cfd --- /dev/null +++ b/batch-quartz/src/main/resources/templates/layouts/layout.html @@ -0,0 +1,16 @@ + + + + +
+
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/layouts/signin-layout.html b/batch-quartz/src/main/resources/templates/layouts/signin-layout.html new file mode 100644 index 0000000..99320b4 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/layouts/signin-layout.html @@ -0,0 +1,10 @@ + + + + +
+
+ + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/pages/error/error.html b/batch-quartz/src/main/resources/templates/pages/error/error.html new file mode 100644 index 0000000..527f8a3 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/pages/error/error.html @@ -0,0 +1,12 @@ + + + + + Error page + + + +

Error Page

+ + + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/pages/main/main.html b/batch-quartz/src/main/resources/templates/pages/main/main.html new file mode 100644 index 0000000..343213b --- /dev/null +++ b/batch-quartz/src/main/resources/templates/pages/main/main.html @@ -0,0 +1,11 @@ + + + +
+ 본문 영역입니다. +
+ + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/pages/sign/sign-in.html b/batch-quartz/src/main/resources/templates/pages/sign/sign-in.html new file mode 100644 index 0000000..25b9d77 --- /dev/null +++ b/batch-quartz/src/main/resources/templates/pages/sign/sign-in.html @@ -0,0 +1,35 @@ + + + + 로그인 페이지 + + +
+
+
+ User Icon +
+

로그인

+
+
+
+ User Icon + +
+
+
+
+ Password Icon + +
+
+ +
+ +
+ +
+ + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/pages/sign/sign-up.html b/batch-quartz/src/main/resources/templates/pages/sign/sign-up.html new file mode 100644 index 0000000..c3f10ab --- /dev/null +++ b/batch-quartz/src/main/resources/templates/pages/sign/sign-up.html @@ -0,0 +1,28 @@ + + + + + 회원가입 페이지 + + + + + + +
+

회원가입

+
+
+ +
+
+ +
+
+ +
+ +
+
+ + \ No newline at end of file diff --git a/batch-quartz/src/main/resources/templates/views/user/signIn.html b/batch-quartz/src/main/resources/templates/views/user/signIn.html deleted file mode 100644 index 36a4adb..0000000 --- a/batch-quartz/src/main/resources/templates/views/user/signIn.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - 로그인 페이지 - - - - - -
-
- User Icon -
-

로그인

-
-
-
- User Icon - -
-
-
-
- Password Icon - -
-
- -
-
- - \ No newline at end of file