diff --git a/src/main/java/land/leets/domain/shared/AuthRole.java b/src/main/java/land/leets/domain/shared/AuthRole.java deleted file mode 100644 index 544a925..0000000 --- a/src/main/java/land/leets/domain/shared/AuthRole.java +++ /dev/null @@ -1,13 +0,0 @@ -package land.leets.domain.shared; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum AuthRole { - ROLE_USER("ROLE_USER"), - ROLE_ADMIN("ROLE_ADMIN"); - - private final String role; -} diff --git a/src/main/java/land/leets/global/advice/ExceptionHandleAdvice.java b/src/main/java/land/leets/global/advice/ExceptionHandleAdvice.java deleted file mode 100644 index b1b6aff..0000000 --- a/src/main/java/land/leets/global/advice/ExceptionHandleAdvice.java +++ /dev/null @@ -1,50 +0,0 @@ -package land.leets.global.advice; - -import land.leets.global.error.ErrorCode; -import land.leets.global.error.ErrorResponse; -import land.leets.global.error.exception.ServiceException; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingRequestCookieException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.util.stream.Collectors; - -@RestControllerAdvice -public class ExceptionHandleAdvice { - - @ExceptionHandler(ServiceException.class) - public ResponseEntity handleServiceException(ServiceException ex) { - ErrorResponse response = new ErrorResponse(ex.getErrorCode()); - return ResponseEntity.status(ex.httpStatusCode()).body(response); - } - - @ExceptionHandler(MissingRequestCookieException.class) - public ResponseEntity handleMissingRequestCookieException(MissingRequestCookieException ex) { - ErrorResponse response = new ErrorResponse(ErrorCode.COOKIE_NOT_FOUND); - return ResponseEntity.status(ex.getStatusCode()).body(response); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception ex) { - ex.printStackTrace(); - ErrorResponse response = new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR); - return ResponseEntity.internalServerError().body(response); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { - String fieldErrors = ex.getBindingResult() - .getFieldErrors() - .stream() - .map(FieldError::getField) - .collect(Collectors.joining(", ")); - - String customMessage = String.format(ErrorCode.INVALID_REQUEST_BODY.getMessage(), fieldErrors); - ErrorResponse response = new ErrorResponse(ErrorCode.INVALID_REQUEST_BODY, customMessage); - - return ResponseEntity.status(ErrorCode.INVALID_REQUEST_BODY.getHttpStatus()).body(response); - } -} diff --git a/src/main/java/land/leets/global/config/CorsConfig.java b/src/main/java/land/leets/global/config/CorsConfig.java deleted file mode 100644 index fcc1bbc..0000000 --- a/src/main/java/land/leets/global/config/CorsConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package land.leets.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class CorsConfig implements WebMvcConfigurer { - - @Value("${cors.origin.development}") - private String developmentOrigin; - - @Value("${cors.origin.production}") - private String productionOrigin; - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedMethods("OPTIONS", "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE") - .allowCredentials(true) - .allowedOrigins(developmentOrigin, productionOrigin, "http://localhost:3000") - .maxAge(3000); - } -} diff --git a/src/main/java/land/leets/global/config/SecurityConfig.java b/src/main/java/land/leets/global/config/SecurityConfig.java deleted file mode 100644 index a9fe874..0000000 --- a/src/main/java/land/leets/global/config/SecurityConfig.java +++ /dev/null @@ -1,103 +0,0 @@ -package land.leets.global.config; - -import land.leets.domain.auth.exception.PermissionDeniedException; -import land.leets.domain.shared.AuthRole; -import land.leets.global.filter.ExceptionHandleFilter; -import land.leets.global.jwt.JwtFilter; -import land.leets.global.jwt.JwtProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsUtils; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class SecurityConfig { - - private final JwtProvider jwtProvider; - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .cors(Customizer.withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - - http - .exceptionHandling(exception -> exception - .authenticationEntryPoint((request, response, authException) -> { - throw new PermissionDeniedException(); - }) - .accessDeniedHandler((request, response, authException) -> { - throw new PermissionDeniedException(); - })); - - //요청에 대한 권한 설정 - http - .authorizeHttpRequests(auth -> auth - .requestMatchers(CorsUtils::isCorsRequest).permitAll() - - .requestMatchers(HttpMethod.GET, "/health-check").permitAll() - - .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll() - - .requestMatchers("/oauth2/**", "/auth/**").permitAll() - .requestMatchers("/login/oauth2/callback/*").permitAll() - .requestMatchers("/oauth2/authorization/*").permitAll() - - .requestMatchers("/user/login", "/admin/login").permitAll() - .requestMatchers("/user/refresh", "/admin/refresh").permitAll() - - .requestMatchers("/user/me").hasAuthority(AuthRole.ROLE_USER.getRole()) - .requestMatchers("/admin/me").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - - .requestMatchers(HttpMethod.GET, "/application").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - .requestMatchers(HttpMethod.POST, "/application").hasAuthority(AuthRole.ROLE_USER.getRole()) - .requestMatchers(HttpMethod.PATCH, "/application").hasAuthority(AuthRole.ROLE_USER.getRole()) - .requestMatchers(HttpMethod.GET, "/application/me").hasAuthority(AuthRole.ROLE_USER.getRole()) - .requestMatchers(HttpMethod.GET, "/application/**").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - .requestMatchers(HttpMethod.PATCH, "/application/**").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - - .requestMatchers(HttpMethod.GET, "/interview").permitAll() - .requestMatchers(HttpMethod.PATCH, "/interview/**").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - - .requestMatchers("/comments/**").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - - .requestMatchers(HttpMethod.POST, "/mail/subscribe").permitAll() - .requestMatchers(HttpMethod.POST, "/mail/**").hasAuthority(AuthRole.ROLE_ADMIN.getRole()) - - .requestMatchers("/portfolios/**").permitAll() - .requestMatchers("/images/**").permitAll() - - .anyRequest().authenticated() - ); - - //oauth2Login - http.oauth2Login(Customizer.withDefaults()); - - //jwt filter 설정 - http - .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(new ExceptionHandleFilter(), JwtFilter.class); - - return http.build(); - } -} diff --git a/src/main/java/land/leets/global/error/ErrorResponse.java b/src/main/java/land/leets/global/error/ErrorResponse.java deleted file mode 100644 index b197655..0000000 --- a/src/main/java/land/leets/global/error/ErrorResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package land.leets.global.error; - -import lombok.Getter; - -@Getter -public class ErrorResponse { - - private final int httpStatus; - private final String message; - private final String code; - - public ErrorResponse(ErrorCode errorCode) { - this.httpStatus = errorCode.getHttpStatus(); - this.message = errorCode.getMessage(); - this.code = errorCode.getCode(); - } - - public ErrorResponse(ErrorCode errorCode, String customMessage) { - this.httpStatus = errorCode.getHttpStatus(); - this.message = customMessage; - this.code = errorCode.getCode(); - } - - public static ErrorResponse of(ErrorCode errorCode) { - return new ErrorResponse(errorCode); - } -} diff --git a/src/main/java/land/leets/global/error/exception/ServiceException.java b/src/main/java/land/leets/global/error/exception/ServiceException.java deleted file mode 100644 index 5907399..0000000 --- a/src/main/java/land/leets/global/error/exception/ServiceException.java +++ /dev/null @@ -1,19 +0,0 @@ -package land.leets.global.error.exception; - -import land.leets.global.error.ErrorCode; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public class ServiceException extends RuntimeException { - - private final ErrorCode errorCode; - - public ServiceException(ErrorCode errorCode) { - this.errorCode = errorCode; - } - - public HttpStatus httpStatusCode() { - return HttpStatus.valueOf(errorCode.getHttpStatus()); - } -} diff --git a/src/main/java/land/leets/global/filter/ExceptionHandleFilter.java b/src/main/java/land/leets/global/filter/ExceptionHandleFilter.java deleted file mode 100644 index 4c35ca8..0000000 --- a/src/main/java/land/leets/global/filter/ExceptionHandleFilter.java +++ /dev/null @@ -1,40 +0,0 @@ -package land.leets.global.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import land.leets.global.error.ErrorCode; -import land.leets.global.error.ErrorResponse; -import land.leets.global.error.exception.ServiceException; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class ExceptionHandleFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - try { - filterChain.doFilter(request, response); - } catch (ServiceException e) { - sendErrorResponse(response, e.getErrorCode()); - } catch (Exception e) { - sendErrorResponse(response, ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { - response.setStatus(errorCode.getHttpStatus()); - response.setCharacterEncoding("UTF-8"); - response.setContentType("application/json"); - ObjectMapper objectMapper = new ObjectMapper(); - ErrorResponse errorResponse = ErrorResponse.of(errorCode); - Map result = new HashMap<>(); - result.put("result", errorResponse); - response.getWriter().write(objectMapper.writeValueAsString(result)); - } -} diff --git a/src/main/java/land/leets/global/jwt/JwtFilter.java b/src/main/java/land/leets/global/jwt/JwtFilter.java deleted file mode 100644 index 78d057d..0000000 --- a/src/main/java/land/leets/global/jwt/JwtFilter.java +++ /dev/null @@ -1,54 +0,0 @@ -package land.leets.global.jwt; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.List; - -@RequiredArgsConstructor -public class JwtFilter extends OncePerRequestFilter { - - private static final String AUTHORIZATION = "Authorization"; - private static final String BEARER = "Bearer"; - private static final List IGNORE_JWT_FILTER_API = List.of( - "/user/login", - "/user/refresh", - "/admin/login", - "/admin/refresh" - ); - - private final JwtProvider jwtProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (IGNORE_JWT_FILTER_API.contains(request.getRequestURI())) { - filterChain.doFilter(request, response); - return; - } - - String token = resolveToken(request); - if (token != null) { - jwtProvider.validateToken(token, false); - Authentication authentication = this.jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - filterChain.doFilter(request, response); - } - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER)) { - return bearerToken.split(" ")[1]; - } - return null; - } -} diff --git a/src/main/java/land/leets/global/jwt/JwtProvider.java b/src/main/java/land/leets/global/jwt/JwtProvider.java deleted file mode 100644 index 1f5274a..0000000 --- a/src/main/java/land/leets/global/jwt/JwtProvider.java +++ /dev/null @@ -1,95 +0,0 @@ -package land.leets.global.jwt; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; -import land.leets.domain.auth.AdminAuthDetailsService; -import land.leets.domain.auth.AuthDetails; -import land.leets.domain.auth.UserAuthDetailsService; -import land.leets.domain.shared.AuthRole; -import land.leets.global.jwt.exception.ExpiredTokenException; -import land.leets.global.jwt.exception.InvalidTokenException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.UUID; - -@Component -public class JwtProvider { - private final SecretKey accessSecret; - private final SecretKey refreshSecret; - private final UserAuthDetailsService userAuthDetailsService; - private final AdminAuthDetailsService adminAuthDetailsService; - - @Autowired - public JwtProvider(@Value("${jwt.auth.access_secret}") String accessSecret, - @Value("${jwt.auth.refresh_secret}") String refreshSecret, - UserAuthDetailsService userAuthDetailsService, - AdminAuthDetailsService adminAuthDetailsService) { - this.accessSecret = Keys.hmacShaKeyFor(accessSecret.getBytes(StandardCharsets.UTF_8)); - this.refreshSecret = Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8)); - this.userAuthDetailsService = userAuthDetailsService; - this.adminAuthDetailsService = adminAuthDetailsService; - } - - public String generateToken(UUID uuid, String sub, AuthRole role, boolean isRefreshToken) { - Instant accessDate = LocalDateTime.now().plusHours(6).atZone(ZoneId.systemDefault()).toInstant(); - Instant refreshDate = LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant(); - - return Jwts.builder() - .claim("role", role.getRole()) - .claim("id" , uuid.toString()) - .subject(sub) - .expiration(isRefreshToken ? Date.from(refreshDate) : Date.from(accessDate)) - .signWith(isRefreshToken ? refreshSecret : accessSecret) - .compact(); - } - - public Authentication getAuthentication(String token) { - Claims claims = parseClaims(token, false); - Collection authorities = Collections.singletonList(new SimpleGrantedAuthority(claims.get("role").toString())); - return new UsernamePasswordAuthenticationToken(getDetails(claims), "", authorities); - } - - private AuthDetails getDetails(Claims claims) { - if (claims.get("role").equals(AuthRole.ROLE_ADMIN.getRole())) { - return this.adminAuthDetailsService.loadUserByUsername(claims.getSubject()); - } - return this.userAuthDetailsService.loadUserByUsername(claims.getSubject()); - } - - public void validateToken(String token, boolean isRefreshToken) { - try { - parseClaims(token, isRefreshToken); - } catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) { - throw new InvalidTokenException(); - } catch (ExpiredJwtException e) { - throw new ExpiredTokenException(); - } - } - - public Claims parseClaims(String token, boolean isRefreshToken) { - try { - return Jwts.parser() - .verifyWith(isRefreshToken ? refreshSecret : accessSecret) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (ExpiredJwtException e) { - return e.getClaims(); - } - } -} diff --git a/src/main/java/land/leets/global/jwt/dto/JwtResponse.java b/src/main/java/land/leets/global/jwt/dto/JwtResponse.java deleted file mode 100644 index e2628c7..0000000 --- a/src/main/java/land/leets/global/jwt/dto/JwtResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package land.leets.global.jwt.dto; - -import lombok.Builder; - -@Builder -public class JwtResponse { - private final String accessToken; - private final String refreshToken; - - public JwtResponse(String accessToken, String refreshToken) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - } - - public String getAccessToken() { - return accessToken; - } - - public String getRefreshToken() { - return refreshToken; - } -} diff --git a/src/main/java/land/leets/global/jwt/exception/ExpiredTokenException.java b/src/main/java/land/leets/global/jwt/exception/ExpiredTokenException.java deleted file mode 100644 index 043e922..0000000 --- a/src/main/java/land/leets/global/jwt/exception/ExpiredTokenException.java +++ /dev/null @@ -1,10 +0,0 @@ -package land.leets.global.jwt.exception; - -import land.leets.global.error.ErrorCode; -import land.leets.global.error.exception.ServiceException; - -public class ExpiredTokenException extends ServiceException { - public ExpiredTokenException() { - super(ErrorCode.EXPIRED_TOKEN); - } -} diff --git a/src/main/java/land/leets/global/jwt/exception/InvalidTokenException.java b/src/main/java/land/leets/global/jwt/exception/InvalidTokenException.java deleted file mode 100644 index d78d5be..0000000 --- a/src/main/java/land/leets/global/jwt/exception/InvalidTokenException.java +++ /dev/null @@ -1,10 +0,0 @@ -package land.leets.global.jwt.exception; - -import land.leets.global.error.ErrorCode; -import land.leets.global.error.exception.ServiceException; - -public class InvalidTokenException extends ServiceException { - public InvalidTokenException() { - super(ErrorCode.INVALID_TOKEN); - } -} diff --git a/src/main/kotlin/land/leets/domain/shared/AuthRole.kt b/src/main/kotlin/land/leets/domain/shared/AuthRole.kt new file mode 100644 index 0000000..87d27ac --- /dev/null +++ b/src/main/kotlin/land/leets/domain/shared/AuthRole.kt @@ -0,0 +1,8 @@ +package land.leets.domain.shared + +enum class AuthRole( + val role: String +) { + ROLE_USER("ROLE_USER"), + ROLE_ADMIN("ROLE_ADMIN"); +} diff --git a/src/main/kotlin/land/leets/domain/user/presentation/UserController.kt b/src/main/kotlin/land/leets/domain/user/presentation/UserController.kt index 2f874f9..999eb6c 100644 --- a/src/main/kotlin/land/leets/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/land/leets/domain/user/presentation/UserController.kt @@ -42,7 +42,7 @@ class UserController( fun login(@RequestBody request: LoginRequest): JwtResponse { val user = authService.getUser(request.idToken) - val accessToken = jwtProvider.generateToken(user.id, user.email, AuthRole.ROLE_USER, false) + val accessToken = jwtProvider.generateToken(user.id!!, user.email, AuthRole.ROLE_USER, false) val refreshToken = jwtProvider.generateToken(user.id, user.email, AuthRole.ROLE_USER, true) return JwtResponse(accessToken, refreshToken) diff --git a/src/main/kotlin/land/leets/global/advise/ExceptionHandleAdvice.kt b/src/main/kotlin/land/leets/global/advise/ExceptionHandleAdvice.kt new file mode 100644 index 0000000..77a758a --- /dev/null +++ b/src/main/kotlin/land/leets/global/advise/ExceptionHandleAdvice.kt @@ -0,0 +1,48 @@ +package land.leets.global.advise + +import land.leets.global.error.ErrorCode +import land.leets.global.error.ErrorResponse +import land.leets.global.error.exception.ServiceException +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingRequestCookieException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import java.util.stream.Collectors + +@RestControllerAdvice +class ExceptionHandleAdvice { + @ExceptionHandler(ServiceException::class) + fun handleServiceException(ex: ServiceException): ResponseEntity { + val response = ErrorResponse.from(ex.errorCode) + return ResponseEntity.status(ex.httpStatusCode()).body(response) + } + + @ExceptionHandler(MissingRequestCookieException::class) + fun handleMissingRequestCookieException(ex: MissingRequestCookieException): ResponseEntity { + val response = ErrorResponse.from(ErrorCode.COOKIE_NOT_FOUND) + return ResponseEntity.status(ex.statusCode).body(response) + } + + @ExceptionHandler(Exception::class) + fun handleException(ex: Exception): ResponseEntity { + ex.printStackTrace() + val response = ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR) + return ResponseEntity.internalServerError().body(response) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity { + val fieldErrors = ex.bindingResult + .fieldErrors + .stream() + .map { obj: FieldError? -> obj!!.field } + .collect(Collectors.joining(", ")) + + val customMessage = String.format(ErrorCode.INVALID_REQUEST_BODY.message, fieldErrors) + val response = ErrorResponse.of(ErrorCode.INVALID_REQUEST_BODY, customMessage) + + return ResponseEntity.status(ErrorCode.INVALID_REQUEST_BODY.httpStatus).body(response) + } +} diff --git a/src/main/kotlin/land/leets/global/config/CorsConfig.kt b/src/main/kotlin/land/leets/global/config/CorsConfig.kt new file mode 100644 index 0000000..d0a880e --- /dev/null +++ b/src/main/kotlin/land/leets/global/config/CorsConfig.kt @@ -0,0 +1,25 @@ +package land.leets.global.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class CorsConfig( + @Value("\${cors.origin.development}") + private val developmentOrigin: String, + + @Value("\${cors.origin.production}") + private val productionOrigin: String +) : WebMvcConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**").apply { + allowedMethods("OPTIONS", "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE") + allowCredentials(true) + allowedOrigins(developmentOrigin, productionOrigin, "http://localhost:3000") + maxAge(3000) + } + } +} diff --git a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt new file mode 100644 index 0000000..4e47c22 --- /dev/null +++ b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt @@ -0,0 +1,121 @@ +package land.leets.global.config + +import land.leets.domain.auth.exception.PermissionDeniedException +import land.leets.domain.shared.AuthRole +import land.leets.global.filter.ExceptionHandleFilter +import land.leets.global.jwt.JwtFilter +import land.leets.global.jwt.JwtProvider +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +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.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtProvider: JwtProvider +) { + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + + http { + httpBasic { disable() } + formLogin { disable() } + cors { } + csrf { disable() } + sessionManagement { SessionCreationPolicy.STATELESS } + + } + + http { + exceptionHandling { + authenticationEntryPoint = AuthenticationEntryPoint { _, _, _ -> throw PermissionDeniedException() } + accessDeniedHandler = AccessDeniedHandler { _, _, _ -> throw PermissionDeniedException() } + } + } + + http { + authorizeHttpRequests { + + // CORS preflight + authorize(HttpMethod.OPTIONS, "/**", permitAll) + + // health check + authorize(HttpMethod.GET, "/health-check", permitAll) + + // swagger + authorize("/v3/api-docs/**", permitAll) + authorize("/swagger-ui/**", permitAll) + + // oauth2 + authorize("/oauth2/**", permitAll) + authorize("/auth/**", permitAll) + authorize(HttpMethod.GET, "/login/oauth2/callback/google", permitAll) + + // auth + authorize(HttpMethod.POST, "/user/login", permitAll) + authorize(HttpMethod.POST, "/admin/login", permitAll) + authorize(HttpMethod.POST, "/admin/refresh", permitAll) + + // user info + authorize(HttpMethod.GET, "/user/me", hasAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.GET, "/admin/me", hasAuthority(AuthRole.ROLE_ADMIN.role)) + + // applications + authorize(HttpMethod.GET, "/application", hasAuthority(AuthRole.ROLE_ADMIN.role)) + authorize(HttpMethod.POST, "/application", hasAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.PATCH, "/application", hasAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.GET, "/application/me", hasAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.GET, "/application/{id}", hasAuthority(AuthRole.ROLE_ADMIN.role)) + authorize(HttpMethod.PATCH, "/application/{id}", hasAuthority(AuthRole.ROLE_ADMIN.role)) + + // interviews + authorize(HttpMethod.PATCH, "/interview", hasAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.POST, "/interview/{id}", hasAuthority(AuthRole.ROLE_ADMIN.role)) + authorize(HttpMethod.PATCH, "/interview/{id}", hasAuthority(AuthRole.ROLE_ADMIN.role)) + + // comments + authorize(HttpMethod.POST, "/comments", hasAuthority(AuthRole.ROLE_ADMIN.role)) + authorize(HttpMethod.GET, "/comments/{applicationId}", hasAuthority(AuthRole.ROLE_ADMIN.role)) + + // portfolios + authorize(HttpMethod.GET, "/portfolios", permitAll) + authorize(HttpMethod.GET, "/portfolios/{portfolioId}", permitAll) + + // images + authorize(HttpMethod.GET, "/images/{imageName}", permitAll) + + // default + authorize(anyRequest, denyAll) + } + } + + http { + oauth2Login { } + } + + http { + addFilterBefore( + filter = JwtFilter(jwtProvider) + ) + addFilterBefore( + filter = ExceptionHandleFilter() + ) + } + + return http.build() + } +} diff --git a/src/main/java/land/leets/global/error/ErrorCode.java b/src/main/kotlin/land/leets/global/error/ErrorCode.kt similarity index 86% rename from src/main/java/land/leets/global/error/ErrorCode.java rename to src/main/kotlin/land/leets/global/error/ErrorCode.kt index 14da5dd..47bf4cd 100644 --- a/src/main/java/land/leets/global/error/ErrorCode.java +++ b/src/main/kotlin/land/leets/global/error/ErrorCode.kt @@ -1,12 +1,10 @@ -package land.leets.global.error; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum ErrorCode { +package land.leets.global.error +enum class ErrorCode( + val httpStatus: Int, + val code: String, + val message: String +) { INVALID_REQUEST_BODY(400, "INVALID_REQUEST_BODY", "올바르지 않은 요청입니다 [%s]"), PATCH_REQUEST_FAIL(404, "PATCH_REQUEST_FAIL", "PATCH 요청에 실패했습니다."), INTERVIEW_NOT_FOUND(404, "INTERVIEW_NOT_FOUND", "면접 정보를 찾을 수 없습니다."), @@ -24,8 +22,4 @@ public enum ErrorCode { PORTFOLIO_NOT_FOUND(404, "PORTFOLIO_NOT_FOUND", "포트폴리오를 찾을 수 없습니다."), MAIL_SEND_FAIL(500, "MAIL_SEND_FAIL", "메일 전송에 실패했습니다"), INTERNAL_SERVER_ERROR(500, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."); - - private final int httpStatus; - private final String code; - private final String message; } diff --git a/src/main/kotlin/land/leets/global/error/ErrorResponse.kt b/src/main/kotlin/land/leets/global/error/ErrorResponse.kt new file mode 100644 index 0000000..8451297 --- /dev/null +++ b/src/main/kotlin/land/leets/global/error/ErrorResponse.kt @@ -0,0 +1,26 @@ +package land.leets.global.error + +data class ErrorResponse( + val httpStatus: Int, + val message: String, + val code: String +) { + + companion object { + fun from(errorCode: ErrorCode) = + ErrorResponse( + errorCode.httpStatus, + errorCode.message, + errorCode.code + ) + + fun of( + errorCode: ErrorCode, + customMessage: String + ) = ErrorResponse( + errorCode.httpStatus, + customMessage, + errorCode.code + ) + } +} diff --git a/src/main/kotlin/land/leets/global/error/exception/ServiceException.kt b/src/main/kotlin/land/leets/global/error/exception/ServiceException.kt new file mode 100644 index 0000000..afe5b29 --- /dev/null +++ b/src/main/kotlin/land/leets/global/error/exception/ServiceException.kt @@ -0,0 +1,12 @@ +package land.leets.global.error.exception + +import land.leets.global.error.ErrorCode +import org.springframework.http.HttpStatus + +open class ServiceException( + val errorCode: ErrorCode +) : RuntimeException() { + fun httpStatusCode(): HttpStatus { + return HttpStatus.valueOf(errorCode.httpStatus) + } +} diff --git a/src/main/kotlin/land/leets/global/filter/ExceptionHandleFilter.kt b/src/main/kotlin/land/leets/global/filter/ExceptionHandleFilter.kt new file mode 100644 index 0000000..913fb10 --- /dev/null +++ b/src/main/kotlin/land/leets/global/filter/ExceptionHandleFilter.kt @@ -0,0 +1,45 @@ +package land.leets.global.filter + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import land.leets.global.error.ErrorCode +import land.leets.global.error.ErrorResponse +import land.leets.global.error.exception.ServiceException +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException + +class ExceptionHandleFilter : OncePerRequestFilter() { + + @Throws(IOException::class) + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + runCatching { + filterChain.doFilter(request, response) + }.onFailure { exception -> + val errorCode = when (exception) { + is ServiceException -> exception.errorCode + else -> ErrorCode.INTERNAL_SERVER_ERROR + } + sendErrorResponse(response, errorCode) + } + } + + @Throws(IOException::class) + private fun sendErrorResponse(response: HttpServletResponse, errorCode: ErrorCode) { + response.apply { + status = errorCode.httpStatus + characterEncoding = "UTF-8" + contentType = "application/json" + + val errorResponse = ErrorResponse.from(errorCode) + val result = mapOf("result" to errorResponse) + + writer.write(ObjectMapper().writeValueAsString(result)) + } + } +} diff --git a/src/main/kotlin/land/leets/global/jwt/JwtFilter.kt b/src/main/kotlin/land/leets/global/jwt/JwtFilter.kt new file mode 100644 index 0000000..5dd022e --- /dev/null +++ b/src/main/kotlin/land/leets/global/jwt/JwtFilter.kt @@ -0,0 +1,49 @@ +package land.leets.global.jwt + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException + +class JwtFilter( + private val jwtProvider: JwtProvider +) : OncePerRequestFilter() { + companion object { + private const val AUTHORIZATION = "Authorization" + private const val BEARER = "Bearer" + private val IGNORE_JWT_FILTER_API = listOf( + "/user/login", + "/admin/login", + "/admin/refresh" + ) + } + + @Throws(ServletException::class, IOException::class) + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + if (request.requestURI in IGNORE_JWT_FILTER_API) { + return filterChain.doFilter(request, response) + } + + resolveToken(request)?.let { token -> + jwtProvider.validateToken(token, false) + jwtProvider.getAuthentication(token).also { authentication -> + SecurityContextHolder.getContext().authentication = authentication + } + } + + filterChain.doFilter(request, response) + } + + private fun resolveToken(request: HttpServletRequest): String? = + request.getHeader(AUTHORIZATION) + ?.takeIf { it.startsWith(BEARER) && StringUtils.hasText(it) } + ?.substringAfter("$BEARER ") +} diff --git a/src/main/kotlin/land/leets/global/jwt/JwtProvider.kt b/src/main/kotlin/land/leets/global/jwt/JwtProvider.kt new file mode 100644 index 0000000..7a7ee19 --- /dev/null +++ b/src/main/kotlin/land/leets/global/jwt/JwtProvider.kt @@ -0,0 +1,92 @@ +package land.leets.global.jwt + +import io.jsonwebtoken.* +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SignatureException +import land.leets.domain.auth.AdminAuthDetailsService +import land.leets.domain.auth.AuthDetails +import land.leets.domain.auth.UserAuthDetailsService +import land.leets.domain.shared.AuthRole +import land.leets.global.jwt.exception.ExpiredTokenException +import land.leets.global.jwt.exception.InvalidTokenException +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import javax.crypto.SecretKey + +@Component +class JwtProvider( + @Value("\${jwt.auth.access_secret}") + accessSecret: String, + @Value("\${jwt.auth.refresh_secret}") + refreshSecret: String, + private val userAuthDetailsService: UserAuthDetailsService, + private val adminAuthDetailsService: AdminAuthDetailsService +) { + private val accessSecret: SecretKey = Keys.hmacShaKeyFor(accessSecret.toByteArray(StandardCharsets.UTF_8)) + private val refreshSecret: SecretKey = Keys.hmacShaKeyFor(refreshSecret.toByteArray(StandardCharsets.UTF_8)) + + fun generateToken(uuid: UUID, sub: String?, role: AuthRole, isRefreshToken: Boolean): String { + val expirationDate = LocalDateTime.now() + .run { if (isRefreshToken) plusDays(30) else plusDays(1) } + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + return Jwts.builder().apply { + claim("role", role.role) + claim("id", uuid.toString()) + subject(sub) + expiration(expirationDate) + signWith(if (isRefreshToken) refreshSecret else accessSecret) + }.compact() + } + + fun getAuthentication(token: String): Authentication { + val claims = parseClaims(token, false) + val authorities = listOf(SimpleGrantedAuthority(claims["role"].toString())) + return UsernamePasswordAuthenticationToken(getDetails(claims), "", authorities) + } + + private fun getDetails(claims: Claims): AuthDetails = + if (claims["role"]!! == AuthRole.ROLE_ADMIN.role) + adminAuthDetailsService.loadUserByUsername(claims.subject) + else + userAuthDetailsService.loadUserByUsername(claims.subject) + + + fun validateToken(token: String, isRefreshToken: Boolean) = + runCatching { parseClaims(token, isRefreshToken) } + .onFailure { exception -> + when (exception) { + is SignatureException, + is MalformedJwtException, + is UnsupportedJwtException, + is IllegalArgumentException -> throw InvalidTokenException() + + is ExpiredJwtException -> throw ExpiredTokenException() + + else -> throw exception + } + } + + fun parseClaims(token: String, isRefreshToken: Boolean): Claims = + runCatching { + Jwts.parser() + .verifyWith(if (isRefreshToken) refreshSecret else accessSecret) + .build() + .parseSignedClaims(token) + .payload + }.getOrElse { exception -> + when (exception) { + is ExpiredJwtException -> exception.claims + else -> throw exception + } + } +} diff --git a/src/main/kotlin/land/leets/global/jwt/dto/JwtResponse.kt b/src/main/kotlin/land/leets/global/jwt/dto/JwtResponse.kt new file mode 100644 index 0000000..607b44c --- /dev/null +++ b/src/main/kotlin/land/leets/global/jwt/dto/JwtResponse.kt @@ -0,0 +1,6 @@ +package land.leets.global.jwt.dto + +data class JwtResponse( + val accessToken: String, + val refreshToken: String? +) diff --git a/src/main/kotlin/land/leets/global/jwt/exception/ExpiredTokenException.kt b/src/main/kotlin/land/leets/global/jwt/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..c8abd47 --- /dev/null +++ b/src/main/kotlin/land/leets/global/jwt/exception/ExpiredTokenException.kt @@ -0,0 +1,6 @@ +package land.leets.global.jwt.exception + +import land.leets.global.error.ErrorCode +import land.leets.global.error.exception.ServiceException + +class ExpiredTokenException : ServiceException(ErrorCode.EXPIRED_TOKEN) diff --git a/src/main/kotlin/land/leets/global/jwt/exception/InvalidTokenException.kt b/src/main/kotlin/land/leets/global/jwt/exception/InvalidTokenException.kt new file mode 100644 index 0000000..b7cbce1 --- /dev/null +++ b/src/main/kotlin/land/leets/global/jwt/exception/InvalidTokenException.kt @@ -0,0 +1,6 @@ +package land.leets.global.jwt.exception + +import land.leets.global.error.ErrorCode +import land.leets.global.error.exception.ServiceException + +class InvalidTokenException : ServiceException(ErrorCode.INVALID_TOKEN) diff --git a/src/test/kotlin/land/leets/domain/admin/usecase/AdminRefreshTokenImplTest.kt b/src/test/kotlin/land/leets/domain/admin/usecase/AdminRefreshTokenImplTest.kt index 7dad4d0..8614745 100644 --- a/src/test/kotlin/land/leets/domain/admin/usecase/AdminRefreshTokenImplTest.kt +++ b/src/test/kotlin/land/leets/domain/admin/usecase/AdminRefreshTokenImplTest.kt @@ -4,9 +4,7 @@ import io.jsonwebtoken.Claims import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every -import io.mockk.just import io.mockk.mockk -import io.mockk.runs import land.leets.domain.shared.AuthRole import land.leets.global.jwt.JwtProvider import java.util.UUID @@ -30,7 +28,7 @@ class AdminRefreshTokenImplTest : DescribeSpec({ every { claims.get("id", String::class.java) } returns uid.toString() every { claims.subject } returns subject - every { jwtProvider.validateToken(refreshToken, true) } just runs + every { jwtProvider.validateToken(refreshToken, true) } returns Result.success(claims) every { jwtProvider.parseClaims(refreshToken, true) } returns claims every { jwtProvider.generateToken(uid, subject, role, false) } returns newAccessToken