diff --git a/build.gradle b/build.gradle index d5552fc76..924f790ab 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } tasks.named('test') { diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java index f33e0c54c..5ff2d1820 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java @@ -5,6 +5,7 @@ public class SecurityConstant { public static final String REGISTRATION_REQUIRED_HEADER = "Registration-Required"; public static final String TOKEN_ROLE_NAME = "role"; public static final String GITHUB_NAME_ATTR_KEY = "id"; + public static final String ACCESS_TOKEN_HEADER_PREFIX = "Bearer "; private SecurityConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/SwaggerUrlConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/SwaggerUrlConstant.java new file mode 100644 index 000000000..96cbcd862 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/SwaggerUrlConstant.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.global.common.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SwaggerUrlConstant { + SWAGGER_RESOURCES_URL("/swagger-resources/**"), + SWAGGER_UI_URL("/swagger-ui/**"), + SWAGGER_API_DOCS_URL("/v3/api-docs/**"), + ; + + private final String value; +} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java index ad3a16918..28abc5581 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java @@ -7,5 +7,11 @@ private UrlConstant() {} public static final String PROD_CLIENT_URL = "https://onboarding.gdschongik.com"; public static final String DEV_CLIENT_URL = "https://dev-onboarding.gdschongik.com"; public static final String LOCAL_REACT_CLIENT_URL = "http://localhost:3000"; + public static final String LOCAL_REACT_CLIENT_SECURE_URL = "https://localhost:3000"; public static final String LOCAL_VITE_CLIENT_URL = "http://localhost:5173"; + public static final String LOCAL_VITE_CLIENT_SECURE_URL = "https://localhost:5173"; + + public static final String PROD_SERVER_URL = "https://api.gdschongik.com"; + public static final String DEV_SERVER_URL = "https://dev-api.gdschongik.com"; + public static final String LOCAL_SERVER_URL = "http://localhost:8080"; } diff --git a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java index 9b6476cca..0a29d335b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java @@ -2,9 +2,10 @@ import com.gdschongik.gdsc.global.property.JwtProperty; import com.gdschongik.gdsc.global.property.RedisProperty; +import com.gdschongik.gdsc.global.property.SwaggerProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; -@EnableConfigurationProperties({JwtProperty.class, RedisProperty.class}) +@EnableConfigurationProperties({JwtProperty.class, RedisProperty.class, SwaggerProperty.class}) @Configuration public class PropertyConfig {} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/SwaggerConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/SwaggerConfig.java new file mode 100644 index 000000000..e871a7444 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/SwaggerConfig.java @@ -0,0 +1,60 @@ +package com.gdschongik.gdsc.global.config; + +import static com.gdschongik.gdsc.global.common.constant.EnvironmentConstant.*; +import static com.gdschongik.gdsc.global.common.constant.UrlConstant.*; +import static org.springframework.http.HttpHeaders.*; + +import com.gdschongik.gdsc.global.util.EnvironmentUtil; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + + private final EnvironmentUtil environmentUtil; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(swaggerServers()) + .addSecurityItem(securityRequirement()) + .components(authSetting()); + } + + private List swaggerServers() { + Server server = new Server().url(getServerUrl()); + return List.of(server); + } + + private String getServerUrl() { + return switch (environmentUtil.getCurrentProfile()) { + case PROD -> PROD_SERVER_URL; + case DEV -> DEV_SERVER_URL; + default -> LOCAL_SERVER_URL; + }; + } + + private Components authSetting() { + return new Components() + .addSecuritySchemes( + "Authorization", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name(AUTHORIZATION)); + } + + private SecurityRequirement securityRequirement() { + return new SecurityRequirement().addList(AUTHORIZATION); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java index 482c0c4dc..18f9fb40b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java @@ -7,21 +7,31 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gdschongik.gdsc.domain.auth.application.JwtService; import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.global.common.constant.SwaggerUrlConstant; +import com.gdschongik.gdsc.global.property.SwaggerProperty; import com.gdschongik.gdsc.global.security.CustomSuccessHandler; import com.gdschongik.gdsc.global.security.CustomUserService; import com.gdschongik.gdsc.global.security.JwtExceptionFilter; import com.gdschongik.gdsc.global.security.JwtFilter; import com.gdschongik.gdsc.global.util.CookieUtil; import com.gdschongik.gdsc.global.util.EnvironmentUtil; +import java.util.Arrays; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; 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.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -36,25 +46,65 @@ public class WebSecurityConfig { private final CookieUtil cookieUtil; private final ObjectMapper objectMapper; private final EnvironmentUtil environmentUtil; + private final SwaggerProperty swaggerProperty; - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + private static void defaultFilterChain(HttpSecurity http) throws Exception { http.httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .cors(withDefaults()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + } + + @Bean + @Order(1) + @ConditionalOnProperty(name = "spring.profiles.active", havingValue = "dev") + public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); + + http.securityMatcher(getSwaggerUrls()) + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .httpBasic(withDefaults()); + + return http.build(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); http.oauth2Login( oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo.userService(customUserService(memberRepository))) - .successHandler(customSuccessHandler(jwtService, cookieUtil))); + .successHandler(customSuccessHandler(jwtService, cookieUtil)) + .failureHandler((request, response, exception) -> response.setStatus(401))); - http.addFilterBefore(jwtFilter(jwtService, cookieUtil), UsernamePasswordAuthenticationFilter.class); - http.addFilterBefore(jwtExceptionFilter(objectMapper), JwtFilter.class); + http.addFilterAfter(jwtExceptionFilter(objectMapper), LogoutFilter.class); + http.addFilterAfter(jwtFilter(jwtService, cookieUtil), LogoutFilter.class); return http.build(); } + private static String[] getSwaggerUrls() { + return Arrays.stream(SwaggerUrlConstant.values()) + .map(SwaggerUrlConstant::getValue) + .toArray(String[]::new); + } + + @Bean + public InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = User.withUsername(swaggerProperty.getUsername()) + .password(passwordEncoder().encode(swaggerProperty.getPassword())) + .roles("SWAGGER") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public CustomUserService customUserService(MemberRepository memberRepository) { return new CustomUserService(memberRepository); @@ -86,7 +136,9 @@ public CorsConfigurationSource corsConfigurationSource() { if (environmentUtil.isDevProfile()) { configuration.addAllowedOriginPattern(DEV_CLIENT_URL); configuration.addAllowedOriginPattern(LOCAL_REACT_CLIENT_URL); + configuration.addAllowedOriginPattern(LOCAL_REACT_CLIENT_SECURE_URL); configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_URL); + configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_SECURE_URL); } configuration.addAllowedHeader("*"); diff --git a/src/main/java/com/gdschongik/gdsc/global/property/SwaggerProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/SwaggerProperty.java new file mode 100644 index 000000000..01f130866 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/SwaggerProperty.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.global.property; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "swagger") +public class SwaggerProperty { + + private final String username; + private final String password; +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java index 355ab70e9..2d8c664de 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java @@ -1,5 +1,7 @@ package com.gdschongik.gdsc.global.security; +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; + import com.gdschongik.gdsc.domain.auth.application.JwtService; import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; @@ -14,6 +16,7 @@ import java.io.IOException; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -31,9 +34,20 @@ public class JwtFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String accessTokenHeaderValue = extractAccessTokenFromHeader(request); String accessTokenValue = extractTokenValue(JwtConstant.ACCESS_TOKEN, request); String refreshTokenValue = extractTokenValue(JwtConstant.REFRESH_TOKEN, request); + // 헤더에 AT가 있으면 우선적으로 검증 (Swagger 테스트 전용) + if (accessTokenHeaderValue != null) { + AccessTokenDto accessTokenDto = jwtService.retrieveAccessToken(accessTokenHeaderValue); + if (accessTokenDto != null) { + setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + filterChain.doFilter(request, response); + return; + } + } + // AT와 RT 중 하나라도 없으면 실패 if (accessTokenValue == null || refreshTokenValue == null) { filterChain.doFilter(request, response); @@ -71,6 +85,13 @@ private String extractTokenValue(JwtConstant jwtConstant, HttpServletRequest req .orElse(null); } + private String extractAccessTokenFromHeader(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)) + .filter(header -> header.startsWith(ACCESS_TOKEN_HEADER_PREFIX)) + .map(header -> header.replace(ACCESS_TOKEN_HEADER_PREFIX, "")) + .orElse(null); + } + private void setAuthenticationToContext(Long memberId, MemberRole memberRole) { UserDetails userDetails = new PrincipalDetails(memberId, memberRole); Authentication authentication = diff --git a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java index d397e284e..3049c1991 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java @@ -33,7 +33,7 @@ private ResponseCookie generateCookie(String cookieName, String tokenValue, Stri .path("/") .secure(true) .sameSite(sameSite) - .httpOnly(false) + .httpOnly(true) .build(); } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java index 668d2da12..88960abfa 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java @@ -20,15 +20,15 @@ public String getCurrentProfile() { .orElse(LOCAL); } - public Boolean isProdProfile() { + public boolean isProdProfile() { return getActiveProfiles().anyMatch(PROD::equals); } - public Boolean isDevProfile() { + public boolean isDevProfile() { return getActiveProfiles().anyMatch(DEV::equals); } - public Boolean isProdAndDevProfile() { + public boolean isProdAndDevProfile() { return getActiveProfiles().anyMatch(PROD_AND_DEV::contains); } diff --git a/src/main/resources/application-swagger.yml b/src/main/resources/application-swagger.yml new file mode 100644 index 000000000..48c84e48b --- /dev/null +++ b/src/main/resources/application-swagger.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: "security" + +swagger: + username: ${SWAGGER_USER:default} + password: ${SWAGGER_PASSWORD:default} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 19c2b7fac..41ff71fc6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,7 @@ spring: - redis - storage - security + - swagger logging: level: