Skip to content

Commit

Permalink
Feat: security 의존성 추가, cors 설정 (#21)
Browse files Browse the repository at this point in the history
* Chore: security 의존성 추가

* Feat: 토큰 타입을 상수로 정의

* Feat: swagger security 설정 추가

* Feat: auth 관련 error type 추가

* Chore: cors origin 환경 변수 추가

* Feat: security 관련 웹 url 설정 추가

* Test: allow origin 테스트용 추가
  • Loading branch information
WonSteps authored Jul 24, 2024
1 parent e6d1f9d commit d47c2ff
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 5 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ repositories {

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("me.paulschwarz:spring-dotenv:4.0.0")

Expand All @@ -43,6 +44,7 @@ dependencies {
testImplementation("org.testcontainers:postgresql:1.20.0")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Expand Down
22 changes: 22 additions & 0 deletions src/main/java/com/dnd/runus/auth/exception/AuthException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.dnd.runus.auth.exception;

import com.dnd.runus.global.exception.type.ErrorType;
import lombok.Getter;
import org.springframework.security.core.AuthenticationException;

@Getter
public class AuthException extends AuthenticationException {
private final ErrorType type;
private final String message;

public AuthException(ErrorType type, String message) {
super(message);
this.type = type;
this.message = message;
}

@Override
public String toString() {
return "AUTH 에러 타입: " + type + ", 사유: " + message;
}
}
27 changes: 23 additions & 4 deletions src/main/java/com/dnd/runus/global/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.dnd.runus.global.config;

import com.dnd.runus.global.constant.AuthConstant;
import com.dnd.runus.global.exception.type.ApiErrorType;
import com.dnd.runus.global.exception.type.ErrorType;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.info.Info;
Expand All @@ -15,6 +17,7 @@
import io.swagger.v3.oas.models.parameters.QueryParameter;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.springdoc.core.customizers.OpenApiCustomizer;
Expand All @@ -34,6 +37,8 @@
import java.util.function.Function;
import java.util.stream.Stream;

import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.security.config.Elements.JWT;
import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE;

@RequiredArgsConstructor
Expand All @@ -44,7 +49,19 @@ public class SwaggerConfig {

@Bean
OpenAPI openAPI() {
return new OpenAPI().info(info()).addSecurityItem(new SecurityRequirement().addList("JWT"));
SecurityScheme securityScheme = new SecurityScheme()
.name(JWT)
.type(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.scheme(AuthConstant.TOKEN_TYPE.trim())
.bearerFormat(JWT)
.name(AUTHORIZATION)
.description("JWT 토큰을 입력해주세요 (" + AuthConstant.TOKEN_TYPE + "제외)");

return new OpenAPI()
.info(info())
.addSecurityItem(new SecurityRequirement().addList(JWT))
.components(new Components().addSecuritySchemes(JWT, securityScheme));
}

@Bean
Expand Down Expand Up @@ -87,9 +104,11 @@ OperationCustomizer apiErrorTypeCustomizer() {
Schema<?> errorSchema = new Schema<>();
errorSchema.properties(Map.of(
"statusCode",
new Schema<>().type("int").example(type.httpStatus().value()),
"code", new Schema<>().type("string").example(type.code()),
"message", new Schema<>().type("string").example(type.message())));
new Schema<>().type("int").example(type.httpStatus().value()),
"code",
new Schema<>().type("string").example(type.code()),
"message",
new Schema<>().type("string").example(type.message())));
return errorSchema;
};

Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/dnd/runus/global/constant/AuthConstant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.dnd.runus.global.constant;

public final class AuthConstant {
public static final String TOKEN_TYPE = "Bearer ";
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import org.springframework.http.HttpStatus;

import static lombok.AccessLevel.PRIVATE;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.*;

@Getter
Expand All @@ -20,6 +19,14 @@ public enum ErrorType {
UNSUPPORTED_API(BAD_REQUEST, "WEB_004", "지원하지 않는 API입니다"),
COOKIE_NOT_FOND(BAD_REQUEST, "WEB_005", "요청에 쿠키가 필요합니다"),

// AuthErrorType
FAILED_AUTHENTICATION(UNAUTHORIZED, "AUTH_001", "인증에 실패하였습니다"),
INVALID_ACCESS_TOKEN(UNAUTHORIZED, "AUTH_002", "유효하지 않은 토큰입니다"),
EXPIRED_ACCESS_TOKEN(UNAUTHORIZED, "AUTH_003", "만료된 토큰입니다"),
MALFORMED_ACCESS_TOKEN(UNAUTHORIZED, "AUTH_004", "잘못된 형식의 토큰입니다"),
TAMPERED_ACCESS_TOKEN(UNAUTHORIZED, "AUTH_005", "변조된 토큰입니다"),
UNSUPPORTED_JWT_TOKEN(UNAUTHORIZED, "AUTH_006", "지원하지 않는 JWT 토큰입니다"),

// DatabaseErrorType
ENTITY_NOT_FOUND(NOT_FOUND, "DB_001", "해당 엔티티를 찾을 수 없습니다"),
VIOLATION_OCCURRED(NOT_ACCEPTABLE, "DB_002", "저장할 수 없는 값입니다"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.runus.presentation.config;

import com.dnd.runus.presentation.filter.AuthenticationCheckFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ForwardedHeaderFilter;

@RequiredArgsConstructor
@Configuration
public class SecurityFilterConfig {
@Bean
AuthenticationCheckFilter authenticationCheckFilter() {
return new AuthenticationCheckFilter();
}

@Bean
ForwardedHeaderFilter forwardedHeaderFilter() {
return new ForwardedHeaderFilter();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dnd.runus.presentation.config;

import com.dnd.runus.presentation.filter.AuthenticationCheckFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class SecurityHttpConfigurer implements SecurityConfigurer<DefaultSecurityFilterChain, HttpSecurity> {
private final AuthenticationCheckFilter authenticationCheckFilter;

@Override
public void init(HttpSecurity httpSecurity) {}

@Override
public void configure(HttpSecurity httpSecurity) {
httpSecurity.addFilterBefore(authenticationCheckFilter, UsernamePasswordAuthenticationFilter.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.dnd.runus.presentation.config;

import com.dnd.runus.presentation.handler.UnauthorizedHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;
import java.util.stream.Stream;

import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.HttpHeaders.SET_COOKIE;

@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
@Configuration
public class SecurityWebConfig {
private final UnauthorizedHandler unauthorizedHandler;
private final SecurityHttpConfigurer securityHttpConfigurer;

@Value("${app.api.allow-origins}")
private List<String> corsOrigins;

private static final String[] PUBLIC_ENDPOINTS = {
"/api/v1/auth/**",
};

private static final String[] OPEN_API_ENDPOINTS = {
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**",
};

private static final String[] READ_ONLY_ENDPOINTS = {
"/api/v1/examples/**", // TODO: Remove test endpoints
};

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity httpSecurity) throws Exception {
AntPathRequestMatcher[] readOnlyEndpoints = Stream.of(READ_ONLY_ENDPOINTS)
.map(path -> new AntPathRequestMatcher(path, HttpMethod.GET.name()))
.toArray(AntPathRequestMatcher[]::new);

httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> corsConfigurationSource())
.authorizeHttpRequests(auth -> {
auth.requestMatchers(PUBLIC_ENDPOINTS).permitAll();
auth.requestMatchers(OPEN_API_ENDPOINTS).permitAll();
auth.requestMatchers(readOnlyEndpoints).permitAll();
auth.anyRequest().authenticated();
})
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(auth -> auth.authenticationEntryPoint(unauthorizedHandler));

httpSecurity.apply(securityHttpConfigurer);

return httpSecurity.build();
}

@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(corsOrigins);
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setMaxAge(3600L); // Cache preflight
configuration.setExposedHeaders(List.of(SET_COOKIE, AUTHORIZATION));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.dnd.runus.presentation.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class AuthenticationCheckFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
// TODO: Implement authentication check logic
filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dnd.runus.presentation.handler;

import com.dnd.runus.auth.exception.AuthException;
import com.dnd.runus.global.exception.BaseException;
import com.dnd.runus.global.exception.type.ErrorType;
import com.dnd.runus.presentation.dto.response.ApiErrorDto;
Expand All @@ -9,6 +10,7 @@
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
Expand All @@ -23,6 +25,12 @@ public ResponseEntity<ApiErrorDto> handleBaseException(BaseException e) {
return toResponseEntity(e.getType(), e.getMessage());
}

@ExceptionHandler(AuthException.class)
public ResponseEntity<ApiErrorDto> handleAuthException(AuthException e) {
log.warn(e.getMessage(), e);
return toResponseEntity(e.getType(), e.getMessage());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorDto> handleException(Exception e) {
log.error(e.getMessage(), e);
Expand All @@ -34,6 +42,12 @@ public ResponseEntity<ApiErrorDto> handleNoResourceFoundException(NoResourceFoun
return toResponseEntity(ErrorType.UNSUPPORTED_API, e.getMessage());
}

@ExceptionHandler(InsufficientAuthenticationException.class)
public ResponseEntity<ApiErrorDto> handleInsufficientAuthenticationException(
InsufficientAuthenticationException e) {
return toResponseEntity(ErrorType.FAILED_AUTHENTICATION, e.getMessage());
}

////////////////// 직렬화 / 역직렬화 예외
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorDto> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.dnd.runus.presentation.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.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;

@Component
public class UnauthorizedHandler implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;

public UnauthorizedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}

@Override
public void commence(
HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
resolver.resolveException(request, response, null, authException);
}
}
2 changes: 2 additions & 0 deletions src/main/resources/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ DATABASE_PORT=
DATABASE_NAME=
DATABASE_USER=
DATABASE_PASSWORD=

ALLOW_ORIGINS=
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ spring:
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}

app:
api:
allow-origins: ${ALLOW_ORIGINS}

---
spring.config.activate.on-profile: local

Expand Down
4 changes: 4 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:16.3://test

app:
api:
allow-origins: "*"

0 comments on commit d47c2ff

Please sign in to comment.