This commit is contained in:
mindol1004
2024-09-05 16:30:42 +09:00
parent f791097b0a
commit a6a7de562e
62 changed files with 1075 additions and 206 deletions

View File

@@ -65,6 +65,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>

View File

@@ -0,0 +1,25 @@
package com.spring.common.advice;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
public class ApiResponse<T> {
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;
}
}

View File

@@ -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<Object> {
@Override
public boolean supports(
@NonNull MethodParameter returnType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType
) {
return CommHttpMessageConverter.class.isAssignableFrom(converterType);
}
@Override
public Object beforeBodyWrite(
@Nullable Object body,
@NonNull MethodParameter returnType,
@NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> 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();
}
}

View File

@@ -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<HttpMessageConverter<?>> converters) {
converters.add(0, new CommHttpMessageConverter(objectMapper));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<RejectedValue> errors) {
super(exceptionRule);
this.errors = errors;
}
public static BizErrorResponse valueOf(ExceptionRule exceptionRule) {
return new BizErrorResponse(exceptionRule, null);
}
public static BizErrorResponse fromFieldError(List<FieldError> fieldErrors) {
List<RejectedValue> errors = fieldErrors.stream()
.map(fieldError -> RejectedValue.of(fieldError.getField(), fieldError.getRejectedValue(), fieldError.getDefaultMessage()))
.collect(Collectors.toList());
return new BizErrorResponse(ExceptionRule.UNPROCESSABLE_ENTITY, errors);
}
}

View File

@@ -1,9 +0,0 @@
package com.spring.common.error;
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}

View File

@@ -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<RejectedValue> errors;
}

View File

@@ -0,0 +1,8 @@
package com.spring.common.error;
import org.springframework.http.HttpStatus;
public interface ErrorRule {
HttpStatus getStatus();
String getMessage();
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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> appUserRoleMap;
@Builder
public AppUser(String loginId, String password, String userName, Set<AppUserRoleMap> appUserRoleMap) {
this.loginId = loginId;
this.password = password;
this.userName = userName;
this.appUserRoleMap = appUserRoleMap;
}
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}
/**

View File

@@ -96,7 +96,7 @@ public final class UserPrincipal implements UserDetails {
*/
@Override
public String getPassword() {
return null;
return appUser.getPassword();
}
/**

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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<String> 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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 인증에서 인증되지 않은 접근을 처리하는 진입점 클래스입니다.
*
* <p>이 클래스는 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근을 시도할 때 호출됩니다.
* 이런 경우 SC_UNAUTHORIZED (401) 응답을 반환합니다.</p>
*
* @author mindol
* @version 1.0
*/
@Component
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 인증되지 않은 접근을 처리합니다.
*
* <p>인증되지 않은 사용자가 보호된 리소스에 접근을 시도할 때 호출됩니다.
* 이 메소드는 SC_UNAUTHORIZED (401) 상태 코드를 응답으로 전송합니다.</p>
*
* @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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -28,12 +28,12 @@ public enum JwtTokenRule {
/**
* 액세스 토큰의 접두사입니다.
*/
ACCESS_PREFIX("access"),
ACCESS_PREFIX("accessToken"),
/**
* 리프레시 토큰의 접두사입니다.
*/
REFRESH_PREFIX("refresh"),
REFRESH_PREFIX("refreshToken"),
/**
* Bearer 인증 스키마의 접두사입니다.

View File

@@ -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<SimpleGrantedAuthority> 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()

View File

@@ -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());
}
}
/**
* 쿠키에서 특정 접두사를 가진 토큰을 추출합니다.
*

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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일
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
expiration: 10080

View File

@@ -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; /* 호버 시 배경색 변경 */
}

View File

@@ -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});
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<arguments.length;t++){var n=arguments[t];for(var o in n)e[o]=n[o]}return e}var t=function t(n,o){function r(t,r,i){if("undefined"!=typeof document){"number"==typeof(i=e({},o,i)).expires&&(i.expires=new Date(Date.now()+864e5*i.expires)),i.expires&&(i.expires=i.expires.toUTCString()),t=encodeURIComponent(t).replace(/%(2[346B]|5E|60|7C)/g,decodeURIComponent).replace(/[()]/g,escape);var c="";for(var u in i)i[u]&&(c+="; "+u,!0!==i[u]&&(c+="="+i[u].split(";")[0]));return document.cookie=t+"="+n.write(r,t)+c}}return Object.create({set:r,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var t=document.cookie?document.cookie.split("; "):[],o={},r=0;r<t.length;r++){var i=t[r].split("="),c=i.slice(1).join("=");try{var u=decodeURIComponent(i[0]);if(o[u]=n.read(c,u),e===u)break}catch(e){}}return e?o[e]:o}},remove:function(t,n){r(t,"",e({},n,{expires:-1}))},withAttributes:function(n){return t(this.converter,e({},this.attributes,n))},withConverter:function(n){return t(e({},this.converter,n),this.attributes)}},{attributes:{value:Object.freeze(o)},converter:{value:Object.freeze(n)}})}({read:function(e){return'"'===e[0]&&(e=e.slice(1,-1)),e.replace(/(%[\dA-F]{2})+/gi,decodeURIComponent)},write:function(e){return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,decodeURIComponent)}},{path:"/"});return t}));

View File

@@ -0,0 +1,8 @@
import {signIn} from '../../apis/user-api.js';
document.getElementById('signinForm').addEventListener('submit', function(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
signIn(username, password);
});

View File

@@ -0,0 +1,9 @@
import {signUp} from '../../apis/user-api.js';
document.getElementById('signupForm').addEventListener('submit', function(event) {
event.preventDefault();
const loginId = document.getElementById('loginId').value;
const password = document.getElementById('password').value;
const userName = document.getElementById('userName').value;
signUp(loginId, password, userName);
});

View File

@@ -1,20 +0,0 @@
import axiosInstance from '../common/axiosInstance.js';
const login = async (username, password) => {
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);
});

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="config" lang="ko" xml:lang="ko">
<link rel="stylesheet" th:href="@{/css/style.css}">
<script th:src="@{/js/lib/axios/axios.min.js}"></script>
<script th:src="@{/js/lib/cookie/js.cookie.min.js}"></script>
</html>

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" xml:lang="ko">
<footer th:fragment="footer">
<p>Footer</p>
</footer>
</html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="header" lang="ko" xml:lang="ko">
<h1>사이트 로고</h1>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="sidebar" lang="ko" xml:lang="ko">
<div class="sidebar">
<nav>
<ul>
<li><a href="/home"></a></li>
<li><a href="/about">소개</a></li>
<li><a href="/services">서비스</a></li>
<li><a href="/contact">연락처</a></li>
</ul>
</nav>
</div>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lagn="ko" xml:lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragments/config :: config"/>
<body>
<div class="container">
<div th:replace="fragments/header::header"></div>
<div class="content">
<div th:replace="fragments/left::sidebar"></div>
<div class="main" layout:fragment="content"></div>
</div>
<footer th:replace="fragments/footer::footer"></footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lagn="ko" xml:lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragments/config :: config"/>
<body>
<section layout:fragment="content"></section>
<footer th:replace="fragments/footer::footer"></footer>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" xml:lang="ko">
<head>
<meta charset="UTF-8">
<title>Error page</title>
<link rel="stylesheet" href="/css/error.css">
</head>
<body>
<h1>Error Page</h1>
<span th:text="${message}"></span>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout}"
layout:fragment="content" lang="ko" xml:lang="ko">
<div>
본문 영역입니다.
</div>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/signin-layout}" lang="ko" xml:lang="ko">
<head>
<title>로그인 페이지</title>
</head>
<body>
<section layout:fragment="content">
<div class="container">
<div class="icon">
<img src="/images/user.png" alt="User Icon">
</div>
<h1>로그인</h1>
<form id="signinForm" method="post">
<div class="input-group">
<div class="input-icon">
<img src="/images/user-id.png" alt="User Icon">
<input type="text" id="username" name="username" placeholder="아이디">
</div>
</div>
<div class="input-group">
<div class="input-icon">
<img src="/images/user-lock.png" alt="Password Icon">
<input type="password" id="password" name="password" placeholder="비밀번호">
</div>
</div>
<button type="submit">로그인</button>
</form>
<button id="signup" th:onclick="|location.href='@{/sign-up}'|" class="small-button">회원가입</button>
</div>
<script type="module" th:src="@{/js/pages/sign/sign-in.js}" defer></script>
</section>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" xml:lang="ko">
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/lib/axios/axios.min.js"></script>
<script src="/js/lib/cookie/js.cookie.min.js"></script>
<script type="module" src="/js/pages/sign/sign-up.js" defer></script>
</head>
<body>
<div class="container">
<h1>회원가입</h1>
<form id="signupForm" method="post">
<div class="input-group">
<input type="text" id="loginId" name="loginId" placeholder="아이디">
</div>
<div class="input-group">
<input type="password" id="password" name="password" placeholder="비밀번호">
</div>
<div class="input-group">
<input type="text" id="userName" name="userName" placeholder="이름">
</div>
<button type="submit">회원가입</button>
</form>
</div>
</body>
</html>

View File

@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" xml:lang="ko">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/lib/axios/axios.min.js"></script>
<script type="module" src="/js/user/signIn.js" defer></script>
</head>
<body>
<div class="container">
<div class="icon">
<img src="/images/user.png" alt="User Icon">
</div>
<h1>로그인</h1>
<form id="signinForm" method="post">
<div class="input-group">
<div class="input-icon">
<img src="/images/user-id.png" alt="User Icon">
<input type="text" id="username" name="username" placeholder="아이디">
</div>
</div>
<div class="input-group">
<div class="input-icon">
<img src="/images/user-lock.png" alt="Password Icon">
<input type="password" id="password" name="password" placeholder="비밀번호">
</div>
</div>
<button type="submit">로그인</button>
</form>
</div>
</body>
</html>