Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Security
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.session:spring-session-data-redis'

Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.ftm.server.infrastructure.security;

import com.ftm.server.infrastructure.security.handler.PermissionDeniedHandler;
import com.ftm.server.infrastructure.security.handler.UnauthenticatedAccessHandler;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {

private final UnauthenticatedAccessHandler unauthenticatedAccessHandler;
private final PermissionDeniedHandler permissionDeniedHandler;

// CORS 에서 허용할 HTTP 메서드 목록
public static final List<HttpMethod> CORS_ALLOWED_METHODS =
List.of(
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.PATCH,
HttpMethod.DELETE,
HttpMethod.HEAD);

// CORS 에서 허용할 도메인 목록
public static final List<String> CORS_ALLOWED_ORIGINS =
List.of(
"http://localhost:8080", // 로컬 환경 서버 도메인
"https://dev-api.fittheman.site", // 개발 환경 서버 도메인
"https://fittheman.site"); // 개발 환경 클라이언트 도메인

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// csrf 비활성화
.csrf(AbstractHttpConfigurer::disable)
// http basic 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
// 폼로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable)
// 로그아웃 비활성화
.logout(AbstractHttpConfigurer::disable)
// 세션 관리
.sessionManagement(
session ->
session.sessionFixation()
.migrateSession() // 세션 고정 보호
.maximumSessions(1) // 동시 로그인 1개 제한
.maxSessionsPreventsLogin(false)) // 기존 세션 만료 후 새 로그인 허용
// 예외 핸들링
.exceptionHandling(
exception ->
exception
// 인증되지 않은 요청 예외 처리
.authenticationEntryPoint(unauthenticatedAccessHandler)
// 접근 권한 부족 예외 처리
.accessDeniedHandler(permissionDeniedHandler))
// cors 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 경로 인가 설정
.authorizeHttpRequests(
authorize -> {
authorize
// 정적 리소스 경로 허용
.requestMatchers("/docs/**")
.permitAll();

// TODO: 요청 허용 특정 API 추가 (회원가입, 로그인 등)

// 그 외 모든 요청은 인증 필요
authorize.anyRequest().authenticated();
});

return http.build();
}

// Password 암호화 설정
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// CORS 설정
@Bean
public UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedHeader("*");
CORS_ALLOWED_ORIGINS.forEach(config::addAllowedOriginPattern);
CORS_ALLOWED_METHODS.forEach(config::addAllowedMethod);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);

return source;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.ftm.server.infrastructure.security.handler;

import com.ftm.server.common.response.ApiResponse;
import com.ftm.server.common.response.enums.ErrorResponseCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
import org.springframework.stereotype.Component;

/** 인증은 되었지만 접근 권한이 없는 경우 예외처리하는 핸들러 */
@Component
@RequiredArgsConstructor
public class PermissionDeniedHandler implements AccessDeniedHandler {

private final SecurityResponseHandler securityResponseHandler;

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
securityResponseHandler.sendResponse(
response, ApiResponse.fail(ErrorResponseCode.NOT_AUTHORIZATION));

SecurityContextPersistenceFilter filter = new SecurityContextPersistenceFilter();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ftm.server.infrastructure.security.handler;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ftm.server.common.response.ApiResponse;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/** 시큐리티 필터단에서 응답을 처리하는 핸들러 */
@Component
@RequiredArgsConstructor
public class SecurityResponseHandler {

private final ObjectMapper objectMapper;

public <T> void sendResponse(HttpServletResponse response, ApiResponse<T> apiResponse)
throws IOException {
String jsonResponse = objectMapper.writeValueAsString(apiResponse);

response.setStatus(apiResponse.getStatus());
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(jsonResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.ftm.server.infrastructure.security.handler;

import com.ftm.server.common.response.ApiResponse;
import com.ftm.server.common.response.enums.ErrorResponseCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

/** 인증이 필요한 요청에서 인증되지 않은 유저가 요청할 경우 예외처리하는 핸들러 */
@Component
@RequiredArgsConstructor
public class UnauthenticatedAccessHandler implements AuthenticationEntryPoint {

private final SecurityResponseHandler securityResponseHandler;

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
securityResponseHandler.sendResponse(
response, ApiResponse.fail(ErrorResponseCode.NOT_AUTHENTICATED));
}
}
25 changes: 6 additions & 19 deletions src/main/resources/application-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,9 @@ spring:
activate:
on-profile: "security"

security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/kakao"
client-name: Kakao
provider: kakao
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-info-authentication-method: header
user-name-attribute: id
kakao:
clinet-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: "${BASE_URL}/api/auth/kakao/callback"
token-uri: "https://kauth.kakao.com/oauth/token"
user-info-uri: "https://kapi.kakao.com/v2/user/me"