diff --git a/src/backend/signaling-server/build.gradle b/src/backend/signaling-server/build.gradle index 64a6d8b5..1a76bc93 100644 --- a/src/backend/signaling-server/build.gradle +++ b/src/backend/signaling-server/build.gradle @@ -29,8 +29,10 @@ ext { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.cloud:spring-cloud-starter' implementation 'org.springframework.cloud:spring-cloud-starter-config' implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' @@ -39,11 +41,29 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-client' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // AWS implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // mysql + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } dependencyManagement { diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/BaseEntityConfig.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/BaseEntityConfig.java new file mode 100644 index 00000000..c8223931 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/BaseEntityConfig.java @@ -0,0 +1,36 @@ +package com.asyncgate.signaling_server.config; + +import com.asyncgate.signaling_server.security.info.CustomUserPrincipal; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +@EnableJpaAuditing +@Configuration +public class BaseEntityConfig { + + @Bean("user-auditorProvider") + public AuditorAware auditorProvider() { + return () -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return Optional.of("AnonymousNULL"); + } + + Object principal = authentication.getPrincipal(); + + if (principal instanceof CustomUserPrincipal) { + return Optional.of(((CustomUserPrincipal) principal).getId()); + } + + return Optional.of("AnonymousNOT_TYPE"); + }; + } + +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/CorsConfig.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/CorsConfig.java new file mode 100644 index 00000000..6e33d9bd --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/config/CorsConfig.java @@ -0,0 +1,45 @@ +package com.asyncgate.signaling_server.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.ArrayList; +import java.util.Collections; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CorsConfig { + + public static CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + //리소스를 허용 + ArrayList allowedOriginPatterns = new ArrayList<>(); + allowedOriginPatterns.add("http://localhost:5173"); // vite + allowedOriginPatterns.add("http://127.0.0.1:5173"); + configuration.setAllowedOrigins(allowedOriginPatterns); + + //허용하는 HTTP METHOD + ArrayList allowedHttpMethods = new ArrayList<>(); + allowedHttpMethods.add("GET"); + allowedHttpMethods.add("POST"); + allowedHttpMethods.add("PUT"); + allowedHttpMethods.add("PATCH"); + allowedHttpMethods.add("DELETE"); + allowedHttpMethods.add("OPTIONS"); + configuration.setAllowedMethods(allowedHttpMethods); + + configuration.setAllowedHeaders(Collections.singletonList("*")); +// configuration.setAllowedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE)); + + //인증, 인가를 위한 credentials 를 TRUE로 설정 + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/domain/Identifiable.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/domain/Identifiable.java new file mode 100644 index 00000000..2c1ffa9c --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/domain/Identifiable.java @@ -0,0 +1,5 @@ +package com.asyncgate.signaling_server.domain; + +public interface Identifiable { + String getId(); +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseEntity.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseEntity.java new file mode 100644 index 00000000..bd1619ad --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseEntity.java @@ -0,0 +1,35 @@ +package com.asyncgate.signaling_server.entity.common; + +import com.asyncgate.user_server.entity.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity extends BaseTimeEntity { + + @CreatedBy + @Column(updatable = false) + private String createdBy; + + @LastModifiedBy + private String lastModifiedBy; + + private boolean deleted; + + // 재활성화 - soft delete + public void activate() { + this.deleted = false; + } + + // 비활성화 - soft delete + public void deactivate() { + this.deleted = true; + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseTimeEntity.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseTimeEntity.java new file mode 100644 index 00000000..92a900bc --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/entity/common/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.asyncgate.signaling_server.entity.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime lastModifiedDate; + +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/exception/FailType.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/exception/FailType.java index 2b0abcf2..ab1c6104 100644 --- a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/exception/FailType.java +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/exception/FailType.java @@ -12,11 +12,19 @@ public enum FailType { // 알 수 없는 에러 _UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Server_5000", "알 수 없는 에러가 발생하였습니다."), + _CONCURRENT_UPDATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Server_5001", "동시 업데이트 에러가 발생하였습니다."), // S3 _UPLOAD_FILE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5001", "S3 이미지 업로드에 실패하였습니다."), _DELETE_FILE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5002", "S3 이미지 제거에 실패하였습니다."), - _FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_5003", "파일이 S3에 존재하지 않습니다."); + _FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_5003", "파일이 S3에 존재하지 않습니다."), + + // Unauthorized Error + INVALID_HEADER_ERROR(HttpStatus.UNAUTHORIZED, "Member_40108", "헤더가 올바르지 않습니다."), + + // Access Denied Error + ACCESS_DENIED(HttpStatus.FORBIDDEN, "Access_40300", "접근 권한이 없습니다."), + NOT_LOGIN_USER(HttpStatus.FORBIDDEN, "Access_40301", "로그인하지 않은 사용자입니다."); private final HttpStatus status; private final String errorCode; diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/annotation/MemberID.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/annotation/MemberID.java new file mode 100644 index 00000000..4d2be5ec --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/annotation/MemberID.java @@ -0,0 +1,9 @@ +package com.asyncgate.signaling_server.security.annotation; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberID { +} diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/SecurityConfig.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/SecurityConfig.java new file mode 100644 index 00000000..81bfd040 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.asyncgate.signaling_server.security.config; + +import com.asyncgate.signaling_server.config.CorsConfig; +import com.asyncgate.signaling_server.security.constant.Constants; +import com.asyncgate.signaling_server.security.filter.JsonWebTokenAuthenticationFilter; +import com.asyncgate.signaling_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import com.asyncgate.signaling_server.security.utility.JsonWebTokenUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticateJsonWebTokenUseCase authenticateJsonWebTokenUseCase; + + private final JsonWebTokenUtil jsonWebTokenUtil; + + @Bean + protected SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + return httpSecurity + .cors(cors -> cors + .configurationSource(CorsConfig.corsConfigurationSource()) + ) + .csrf(AbstractHttpConfigurer::disable) + + .httpBasic(AbstractHttpConfigurer::disable) + + .sessionManagement(configurer -> configurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .authorizeHttpRequests(configurer -> configurer + .requestMatchers(Constants.NO_NEED_AUTH_URLS.toArray(String[]::new)).permitAll() + .anyRequest().authenticated() + ) + + // 빈 주입 + .addFilterBefore( + new JsonWebTokenAuthenticationFilter( + authenticateJsonWebTokenUseCase, + jsonWebTokenUtil + ), + LogoutFilter.class + ) + + .getOrBuild(); + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/WebConfig.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/WebConfig.java new file mode 100644 index 00000000..a54c3647 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.asyncgate.signaling_server.security.config; + +import com.asyncgate.signaling_server.security.resolver.HttpMemberIDArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final HttpMemberIDArgumentResolver memberIDArgumentResolver; + + public WebConfig(HttpMemberIDArgumentResolver memberIDArgumentResolver) { + this.memberIDArgumentResolver = memberIDArgumentResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(memberIDArgumentResolver); + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/constant/Constants.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/constant/Constants.java new file mode 100644 index 00000000..5c81714c --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/constant/Constants.java @@ -0,0 +1,41 @@ +package com.asyncgate.signaling_server.security.constant; + +import java.util.List; + +public class Constants { + + // JWT + public static String MEMBER_ID_ATTRIBUTE_NAME = "MEMBER_ID"; + public static String MEMBER_ID_CLAIM_NAME = "mid"; + + // HEADER + public static String BEARER_PREFIX = "Bearer "; + public static String AUTHORIZATION_HEADER = "Authorization"; + + + /** + * 인증이 필요 없는 URL + */ + public static List NO_NEED_AUTH_URLS = List.of( + // Authentication/Authorization + "/", // root + "/actuator/info", + "/health", + + // Swagger + "/api-docs.html", + "/api-docs/**", + "/swagger-ui/**", + "/v3/**" + ); + + /** + * Swagger 에서 사용하는 URL + */ + public static List SWAGGER_URLS = List.of( + "/api-docs.html", + "/api-docs", + "/swagger-ui", + "/v3" + ); +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/exception/CommonException.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/exception/CommonException.java new file mode 100644 index 00000000..2075511e --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/exception/CommonException.java @@ -0,0 +1,16 @@ +package com.asyncgate.signaling_server.security.exception; + +// 각 application에 맞는 failType으로 정의해주세요 ! +import com.asyncgate.signaling_server.exception.FailType; +import lombok.Getter; + +@Getter +public class CommonException extends RuntimeException { + + private final FailType failType; + + public CommonException(FailType failType) { + super(failType.getMessage()); + this.failType = failType; + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/filter/JsonWebTokenAuthenticationFilter.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/filter/JsonWebTokenAuthenticationFilter.java new file mode 100644 index 00000000..0fb6f639 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/filter/JsonWebTokenAuthenticationFilter.java @@ -0,0 +1,79 @@ +package com.asyncgate.signaling_server.security.filter; + +import com.asyncgate.signaling_server.exception.FailType; +import com.asyncgate.signaling_server.security.constant.Constants; +import com.asyncgate.signaling_server.security.exception.CommonException; +import com.asyncgate.signaling_server.security.info.CustomUserPrincipal; +import com.asyncgate.signaling_server.security.utility.HeaderUtil; +import com.asyncgate.signaling_server.security.utility.JsonWebTokenUtil; +import com.asyncgate.signaling_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import io.jsonwebtoken.Claims; +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT를 이용한 인증을 처리하는 필터 + */ +@RequiredArgsConstructor +public class JsonWebTokenAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticateJsonWebTokenUseCase authenticateJsonWebTokenUseCase; + + private final JsonWebTokenUtil jsonWebTokenUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = HeaderUtil.refineHeader(request, Constants.AUTHORIZATION_HEADER, Constants.BEARER_PREFIX) + .orElseThrow(() -> new CommonException(FailType.INVALID_HEADER_ERROR)); + + Claims claims = jsonWebTokenUtil.validate(token); + + String memberId = claims.get(Constants.MEMBER_ID_CLAIM_NAME, String.class); + + CustomUserPrincipal principal = authenticateJsonWebTokenUseCase.execute(memberId); + + // AuthenticationToken 생성 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + principal.getAuthorities() + ); + + // SecurityContext에 AuthenticationToken 저장 + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authenticationToken); + SecurityContextHolder.setContext(context); + + // memberId를 request에 추가 + request.setAttribute(Constants.MEMBER_ID_ATTRIBUTE_NAME, memberId); + + // 다음 필터로 전달 + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + + // 인증이 필요 없는 URL 목록에 포함되는지 확인 + return Constants.NO_NEED_AUTH_URLS.stream() + .anyMatch(excludePattern -> requestURI.matches(excludePattern.replace("**", ".*"))); + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/info/CustomUserPrincipal.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/info/CustomUserPrincipal.java new file mode 100644 index 00000000..94394676 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/info/CustomUserPrincipal.java @@ -0,0 +1,64 @@ +package com.asyncgate.signaling_server.security.info; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +/** + * Spring Security에서 사용하는 UserDetails를 구현한 클래스 + */ +@Builder +@RequiredArgsConstructor +public class CustomUserPrincipal implements UserDetails { + + @Getter + private final String id; + + public static CustomUserPrincipal create(String id) { + return CustomUserPrincipal.builder() + .id(id) + .build(); + } + + @Override + public String getUsername() { + return id; + } + + @Override + public String getPassword() { + return null; + } + + // 임시 권한 user + @Override + public Collection getAuthorities() { + SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority("ROLE_USER"); + Collection authorities = new ArrayList<>(); + authorities.add(adminAuthority); + + return authorities; + } + + public boolean isAccountNonExpired() { + return true; + } + + public boolean isAccountNonLocked() { + return true; + } + + public boolean isCredentialsNonExpired() { + return true; + } + + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/resolver/HttpMemberIDArgumentResolver.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/resolver/HttpMemberIDArgumentResolver.java new file mode 100644 index 00000000..9cdd64cc --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/resolver/HttpMemberIDArgumentResolver.java @@ -0,0 +1,45 @@ +package com.asyncgate.signaling_server.security.resolver; + +import com.asyncgate.signaling_server.exception.FailType; +import com.asyncgate.signaling_server.security.annotation.MemberID; +import com.asyncgate.signaling_server.security.constant.Constants; +import com.asyncgate.signaling_server.security.exception.CommonException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * HTTP 요청에서 Member ID를 추출하는 HandlerMethodArgumentResolver + */ +@Component +public class HttpMemberIDArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(String.class) // 🔥 String으로 변경 + && parameter.hasParameterAnnotation(MemberID.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Object memberId = webRequest.getAttribute( + Constants.MEMBER_ID_ATTRIBUTE_NAME, + WebRequest.SCOPE_REQUEST + ); + + if (memberId == null) { + throw new CommonException(FailType.ACCESS_DENIED); + } + + return memberId.toString(); + } +} diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/service/AuthenticateJsonWebTokenService.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/service/AuthenticateJsonWebTokenService.java new file mode 100644 index 00000000..e311649f --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/service/AuthenticateJsonWebTokenService.java @@ -0,0 +1,17 @@ +package com.asyncgate.signaling_server.security.service; + +import com.asyncgate.signaling_server.security.info.CustomUserPrincipal; +import com.asyncgate.signaling_server.security.usecase.AuthenticateJsonWebTokenUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticateJsonWebTokenService implements AuthenticateJsonWebTokenUseCase { + + @Override + public CustomUserPrincipal execute(final String id) { + // Member 조회 없이, id와 roles를 기반으로 CustomUserPrincipal 생성 + return CustomUserPrincipal.create(id); + } +} diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/usecase/AuthenticateJsonWebTokenUseCase.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/usecase/AuthenticateJsonWebTokenUseCase.java new file mode 100644 index 00000000..313a02fe --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/usecase/AuthenticateJsonWebTokenUseCase.java @@ -0,0 +1,11 @@ +package com.asyncgate.signaling_server.security.usecase; + + +import com.asyncgate.signaling_server.security.info.CustomUserPrincipal; +import org.springframework.stereotype.Component; + +@Component +public interface AuthenticateJsonWebTokenUseCase { + + CustomUserPrincipal execute(String memberId); +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/HeaderUtil.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/HeaderUtil.java new file mode 100644 index 00000000..31c64399 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/HeaderUtil.java @@ -0,0 +1,27 @@ +package com.asyncgate.signaling_server.security.utility; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +/** + * Authorization 헤더를 파싱하는 유틸리티 클래스 + */ +public class HeaderUtil { + + public static Optional refineHeader(final HttpServletRequest request, final String header, final String prefix) { + String unpreparedToken = request.getHeader(header); + + if (!StringUtils.hasText(unpreparedToken)) { + return Optional.empty(); + } + + // prefix가 존재하면 제거하고, 없으면 그대로 반환 + if (unpreparedToken.startsWith(prefix)) { + return Optional.of(unpreparedToken.substring(prefix.length())); + } + + return Optional.of(unpreparedToken); + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/JsonWebTokenUtil.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/JsonWebTokenUtil.java new file mode 100644 index 00000000..33718bd5 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/security/utility/JsonWebTokenUtil.java @@ -0,0 +1,45 @@ +package com.asyncgate.signaling_server.security.utility; + +import com.asyncgate.signaling_server.exception.FailType; +import com.asyncgate.signaling_server.exception.SignalingServerException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; + +/** + * JWT 토큰 생성 및 검증 유틸리티 클래스 + */ +@Component +public class JsonWebTokenUtil implements InitializingBean { + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-expire-period}") + private Long accessTokenExpirePeriod; + + private Key key; + + @Override + public void afterPropertiesSet() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + // token 검증 메서드 + public Claims validate(final String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + // JWT 예외처리는 apigateway에서 처리 + } catch (Exception e) { + throw new SignalingServerException(FailType._UNKNOWN_ERROR); + } + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/annotation/UseCase.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/annotation/UseCase.java new file mode 100644 index 00000000..9bc66623 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/annotation/UseCase.java @@ -0,0 +1,20 @@ +package com.asyncgate.signaling_server.support.annotation; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface UseCase { + /** + * The value may indicate a suggestion for a logical component name, + * to be turned into a Spring bean in case of an autodetected component. + * @return the suggested component name, if any (or empty String otherwise) + */ + @AliasFor(annotation = Component.class) + String value() default ""; +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/handler/GlobalExceptionHandler.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..4ffe47d9 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/handler/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package com.asyncgate.signaling_server.support.handler; + + +import com.asyncgate.signaling_server.exception.SignalingServerException; +import com.asyncgate.signaling_server.support.response.FailResponse; +import jakarta.ws.rs.core.NoContentException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public final class GlobalExceptionHandler { + // UserException을 상속받은 예외를 처리하는 핸들러 + @ExceptionHandler(SignalingServerException.class) + public FailResponse handleGlobalException(SignalingServerException exception) { + return FailResponse.of( + exception.getFailType().getErrorCode(), + exception.getFailType().getMessage(), + exception.getFailType().getStatus().value() + ); + } + + @ExceptionHandler(NoContentException.class) + public ResponseEntity handleNoContentException(NoContentException exception) { + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/logging/RequestResponseLoggingAspect.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/logging/RequestResponseLoggingAspect.java new file mode 100644 index 00000000..43269cd2 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/logging/RequestResponseLoggingAspect.java @@ -0,0 +1,72 @@ +package com.asyncgate.signaling_server.support.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.UUID; + +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestResponseLoggingAspect { + + private static final Logger requestLogger = LoggerFactory.getLogger("HttpRequestLog"); + private static final Logger responseLogger = LoggerFactory.getLogger("HttpResponseLog"); + + @Pointcut("execution(* com.asyncgate.signaling_server.controller..*Controller.*(..))") + public void apiControllerMethods() {} + + @Pointcut("!execution(* com.asyncgate.signaling_server.controller..*Controller.health(..))") + public void excludeHealthCheck() {} + + @Pointcut("apiControllerMethods() && excludeHealthCheck()") + public void apiControllerMethodsExcludingHealthCheck() {} + + @Before("apiControllerMethodsExcludingHealthCheck()") + public void logRequest(JoinPoint joinPoint) { + setMDC(); + requestLogger.info("Request received for method: {}", joinPoint.getSignature().getName()); + } + + @AfterReturning(pointcut = "apiControllerMethodsExcludingHealthCheck()") + public void logResponse() { + String startTimeStr = MDC.get("startTime"); + long startTime = startTimeStr != null ? Long.parseLong(startTimeStr) : 0L; + double executionTime = (System.nanoTime() - startTime) / 1_000_000_000.0; + MDC.put("responseTime", String.format("%.3f초", executionTime)); + responseLogger.info("Response sent successfully"); + } + + @After("apiControllerMethodsExcludingHealthCheck()") + public void clearMDC() { + MDC.clear(); + } + + private void setMDC() { + HttpServletRequest request = getCurrentHttpRequest(); + if (request != null) { + MDC.put("method", request.getMethod()); + MDC.put("requestUri", request.getRequestURI()); + MDC.put("sourceIp", request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr()); + MDC.put("userAgent", request.getHeader("User-Agent")); + MDC.put("xForwardedFor", request.getHeader("X-Forwarded-For")); + MDC.put("xForwardedProto", request.getHeader("X-Forwarded-Proto")); + MDC.put("requestId", UUID.randomUUID().toString()); + MDC.put("startTime", String.valueOf(System.nanoTime())); + } + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } +} \ No newline at end of file diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/DomainUtil.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/DomainUtil.java new file mode 100644 index 00000000..41d5e154 --- /dev/null +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/DomainUtil.java @@ -0,0 +1,7 @@ +package com.asyncgate.signaling_server.support.utility; + +public class DomainUtil { + public static class MemberMapper { + + } +} diff --git a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/utility/S3Util.java b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/S3Util.java similarity index 97% rename from src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/utility/S3Util.java rename to src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/S3Util.java index 830ab850..15893921 100644 --- a/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/utility/S3Util.java +++ b/src/backend/signaling-server/src/main/java/com/asyncgate/signaling_server/support/utility/S3Util.java @@ -1,4 +1,4 @@ -package com.asyncgate.signaling_server.utility; +package com.asyncgate.signaling_server.support.utility; import com.amazonaws.SdkClientException; import com.amazonaws.services.s3.AmazonS3Client; diff --git a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java b/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java deleted file mode 100644 index fabc9a09..00000000 --- a/src/backend/user-server/src/main/java/com/asyncgate/user_server/security/config/BCryptConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.asyncgate.user_server.security.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration -public class BCryptConfig { - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } -} \ No newline at end of file