diff --git a/build.gradle b/build.gradle index 50af1e314..c80b50dbb 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,13 @@ plugins { - id 'org.springframework.boot' version '3.3.5' + id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.6' id 'java-library' - id 'com.diffplug.spotless' version '6.25.0' + id 'com.diffplug.spotless' version '6.23.3' +} + +ext { + set('springCloudVersion', "2025.0.0") + set('querydslVersion', "5.1.0") } spotless { @@ -64,11 +69,9 @@ subprojects { } } - testing { - suites { - test { - useJUnitJupiter() - } + dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } @@ -78,19 +81,20 @@ subprojects { 'org.springframework.boot:spring-boot-configuration-processor', 'jakarta.persistence:jakarta.persistence-api', 'jakarta.annotation:jakarta.annotation-api', - 'com.querydsl:querydsl-apt:5.0.0:jakarta' + "com.querydsl:querydsl-apt:${querydslVersion}:jakarta" ) implementation ( 'org.springframework.boot:spring-boot-starter-web', 'org.springframework.boot:spring-boot-starter-validation', - 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0', + 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0', 'com.google.code.findbugs:jsr305:3.0.2', + 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4', // cloud config - 'org.springframework.cloud:spring-cloud-starter-config:4.1.4', + 'org.springframework.cloud:spring-cloud-starter-config', 'org.springframework.boot:spring-boot-starter-actuator', - 'org.springframework.cloud:spring-cloud-starter-bootstrap:4.1.4', + 'org.springframework.cloud:spring-cloud-starter-bootstrap', // mail 'org.springframework.boot:spring-boot-starter-mail', @@ -111,6 +115,10 @@ subprojects { ) } + test { + useJUnitPlatform() + } + } project(':module-jpa') { @@ -120,8 +128,8 @@ project(':module-jpa') { dependencies { api ( 'org.springframework.boot:spring-boot-starter-data-jpa', - 'com.querydsl:querydsl-jpa:5.0.0:jakarta', - 'com.jcraft:jsch:0.1.55', + "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta", + 'com.jcraft:jsch:0.1.55', // 로컬 개발용 db ssh tunneling, https://mavenlibs.com/maven/dependency/com.jcraft/jsch // 'org.mariadb.jdbc:mariadb-java-client', 'com.mysql:mysql-connector-j', 'com.h2database:h2' @@ -136,18 +144,17 @@ project(':module-auth') { dependencies { api project(':module-jpa') // jwt - api 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', + api 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', // Uncomment the next line if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms: //'org.bouncycastle:bcprov-jdk15on:1.60', - 'io.jsonwebtoken:jjwt-jackson:0.11.5' + 'io.jsonwebtoken:jjwt-jackson:0.11.2' // or 'io.jsonwebtoken:jjwt-gson:0.11.2' for gson // security api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.boot:spring-boot-starter-oauth2-client' - api 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2' testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.mockito:mockito-inline:5.2.0' + testImplementation 'org.mockito:mockito-inline:2.13.0' } } @@ -158,10 +165,8 @@ project(':module-fileStorage') { dependencies { api project(':module-jpa') - api 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2' - implementation 'com.amazonaws:aws-java-sdk-s3:1.12.188' testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.mockito:mockito-inline:5.2.0' + testImplementation 'org.mockito:mockito-inline:2.13.0' } } @@ -171,7 +176,6 @@ project(':resource-server') { api project(':module-auth') api project(':module-fileStorage') api 'org.springframework.boot:spring-boot-starter-security' - implementation 'com.amazonaws:aws-java-sdk-s3:1.12.188' testImplementation 'org.springframework.security:spring-security-test' } @@ -179,7 +183,7 @@ project(':resource-server') { delete file('src/main/generated') } - tasks.register('cleanGeneratedDir', Delete) { + task cleanGeneratedDir(type: Delete) { delete file('src/main/generated') } } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 48c0a02ca..5c82cb032 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/module-auth/src/main/java/com/inhabas/api/auth/config/AuthBeansConfig.java b/module-auth/src/main/java/com/inhabas/api/auth/config/AuthBeansConfig.java index a432e3db6..e5a6d4f39 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/config/AuthBeansConfig.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/config/AuthBeansConfig.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; -import org.springframework.boot.ApplicationRunner; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -36,11 +35,6 @@ public class AuthBeansConfig { private final AuthProperties authProperties; private final RefreshTokenRepository refreshTokenRepository; - @Bean - public ApplicationRunner jwtSecretKeyStrengthChecker(JwtTokenUtil jwtTokenUtil) { - return args -> jwtTokenUtil.validateSecretKeyStrength(); - } - @Bean public HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository() { diff --git a/module-auth/src/main/java/com/inhabas/api/auth/config/AuthSecurityConfig.java b/module-auth/src/main/java/com/inhabas/api/auth/config/AuthSecurityConfig.java index 7479742cc..3a060276e 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/config/AuthSecurityConfig.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/config/AuthSecurityConfig.java @@ -8,7 +8,6 @@ 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.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.web.SecurityFilterChain; @@ -20,8 +19,8 @@ import com.inhabas.api.auth.domain.oauth2.handler.Oauth2AuthenticationSuccessHandler; @Order(0) // 인증 관련 security filter chain 은 우선순위가 가장 높아야 함. -@EnableWebSecurity @Configuration +@EnableWebSecurity @RequiredArgsConstructor @Profile({"dev1", "dev2", "local", "prod1", "prod2"}) // 테스트에는 포함시키지 않음. public class AuthSecurityConfig { @@ -33,25 +32,44 @@ public class AuthSecurityConfig { private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + /** + * 소셜 로그인 api
+ *
+ * 진행과정은 아래와 같다.
+ * + *
    + *
  1. 사용자가 소셜로그인 시작. (프론트에서 redirect_url 보내줘야함.) + *
  2. OAuth2 인증 진행 -> 기존 회원인지 검사 + *
      + *
    1. 성공 -> OAuth2AuthenticationSuccessHandler + *
        + *
      1. 프론트에서 보내준 redirect_url 검증 (-> 실패하면 failure handler 에서 처리) + *
      2. jwt 토큰 발급 및 로그인 처리 + *
      3. 리다이렉트 + *
      + *
    2. 실패 -> OAuth2AuthenticationFailureHandler + *
    + *
+ * + * 회원가입이나, jwt 토큰 발급을 위한 url 로 함부로 접근할 수 없게 하기 위해 jwt 토근이 발급되기 이전까지는 OAuth2 인증 결과를 세션을 통해서 유지함. + * 따라서 critical 한 url 에 대해서 OAuth2 인증이 완료된 세션에 한해서만 허용. + */ @Bean - @Order(0) - public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - // /login/** 경로에만 이 보안 체인 적용 - .securityMatcher("/login/**") + http.securityMatcher("/login/**") // 세션 생성 금지 .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .cors(cors -> {}) - .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.disable()) .authorizeHttpRequests( authorize -> authorize - .requestMatchers(request -> CorsUtils.isPreFlightRequest(request)) + .requestMatchers(CorsUtils::isPreFlightRequest) .permitAll() .anyRequest() .permitAll()) + .csrf(csrf -> csrf.disable()) // Oauth 로그인 설정 .oauth2Login( oauth2 -> @@ -63,6 +81,7 @@ public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exc .baseUri("/login/oauth2/authorization") .authorizationRequestRepository( httpCookieOAuth2AuthorizationRequestRepository)) + // 사용자 정보를 가져오는 엔드포인트에 대한 설정 .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .failureHandler(oauth2AuthenticationFailureHandler) .successHandler(oauth2AuthenticationSuccessHandler)); diff --git a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtils.java b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtils.java index de5eec814..836c9deb7 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtils.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtils.java @@ -1,45 +1,26 @@ package com.inhabas.api.auth.domain.oauth2.cookie; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.time.Duration; import java.util.Base64; +import java.util.Objects; import java.util.Optional; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.ResponseCookie; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.util.SerializationUtils; -public interface CookieUtils { - - enum SameSite { - LAX("Lax"), - STRICT("Strict"), - NONE("None"); - private final String value; - - SameSite(String v) { - this.value = v; - } +import io.micrometer.core.instrument.util.StringUtils; - @Override - public String toString() { - return value; - } - } +public interface CookieUtils { /** request 에 담겨 있는 쿠키를 꺼낸다. */ static Optional resolveCookie(HttpServletRequest request, String cookieName) { Cookie[] cookies = request.getCookies(); - if (cookies != null) { + if (cookies != null && cookies.length > 0) { for (Cookie cookie : cookies) { if (cookie.getName().equals(cookieName)) { return Optional.of(cookie); @@ -50,80 +31,38 @@ static Optional resolveCookie(HttpServletRequest request, String cookieN return Optional.empty(); } - /** 기본 삭제: SameSite=Lax, Secure=request.isSecure(), path="/" 로 설정하여 Max-Age=0 으로 파기. */ + /** 쿠키를 지우는 작업은 없고, maxAge 를 0으로 설정해서 브라우저가 파기하도록 한다. */ static void deleteCookie( HttpServletRequest request, HttpServletResponse response, String cookieName) { - // 동일 이름 쿠키를 빈 값과 Max-Age=0 으로 덮어써서 삭제 - deleteCookie(request, response, cookieName, SameSite.LAX, null); - } - - /** - * 생성 시와 동일한 속성으로 삭제할 수 있도록 SameSite/Secure 를 제어하는 오버로드. secure=null 이면 request.isSecure() 사용. - * SameSite=None 이면 Secure=true 강제. - */ - static void deleteCookie( - HttpServletRequest request, - HttpServletResponse response, - String cookieName, - SameSite sameSite, - Boolean secure) { - - boolean secureFlag = secure != null ? secure : request.isSecure(); - if (sameSite == SameSite.NONE && !secureFlag) { - // 브라우저 정책: SameSite=None 은 Secure 필요. 강제 상승. - secureFlag = true; + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(cookieName)) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + break; + } + } } - - ResponseCookie rc = - ResponseCookie.from(cookieName, "") - .path("/") - .httpOnly(true) - .secure(secureFlag) - .maxAge(Duration.ZERO) - .sameSite(sameSite.toString()) - .build(); - response.addHeader("Set-Cookie", rc.toString()); - } - - /** 기본값: SameSite=Lax, Secure=request.isSecure() */ - static void setCookie( - HttpServletRequest request, - HttpServletResponse response, - String cookieName, - String cookieContents, - int maxAge) { - setCookie(request, response, cookieName, cookieContents, maxAge, SameSite.LAX, null); } /** - * SameSite/Secure 를 제어할 수 있는 오버로드. secure=null 이면 request.isSecure() 사용. SameSite=None 이면 - * Secure=true 강제. + * @param response 응답에 쿠키를 적어서 보내줌 + * @param cookieName key + * @param cookieContents value + * @param maxAge 초 단위 */ static void setCookie( - HttpServletRequest request, - HttpServletResponse response, - String cookieName, - String cookieContents, - int maxAge, - SameSite sameSite, - Boolean secure) { - - boolean secureFlag = secure != null ? secure : request.isSecure(); - if (sameSite == SameSite.NONE && !secureFlag) { - // 브라우저 정책: SameSite=None 은 Secure 필요. 강제 상승. - secureFlag = true; - } + HttpServletResponse response, String cookieName, String cookieContents, int maxAge) { - ResponseCookie rc = - ResponseCookie.from(cookieName, cookieContents) - .path("/") - .httpOnly(true) - .secure(secureFlag) - .maxAge(Duration.ofSeconds(Math.max(0, maxAge))) - .sameSite(sameSite.toString()) - .build(); - response.addHeader("Set-Cookie", rc.toString()); + Cookie cookie = new Cookie(cookieName, cookieContents); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); } /** @@ -131,14 +70,8 @@ static void setCookie( * @return 브라우저 쿠키에 담기 위해 OAuth2AuthorizationRequest 를 string 으로 변환. */ static String serialize(OAuth2AuthorizationRequest request) { - try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(bos)) { - oos.writeObject(request); - oos.flush(); - return Base64.getUrlEncoder().encodeToString(bos.toByteArray()); - } catch (IOException e) { - throw new IllegalStateException("Failed to serialize OAuth2AuthorizationRequest", e); - } + + return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(request)); } /** @@ -148,19 +81,13 @@ static String serialize(OAuth2AuthorizationRequest request) { */ static T deserialize(Cookie cookie, Class clz) { - if (cookie == null || isBlank(cookie.getValue())) return null; - try { - byte[] data = Base64.getUrlDecoder().decode(cookie.getValue()); - try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { - Object obj = ois.readObject(); - return clz.cast(obj); - } - } catch (IOException | ClassNotFoundException | IllegalArgumentException ex) { - return null; - } + if (isDeleted(cookie)) return null; + else + return clz.cast( + SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))); } - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); + private static boolean isDeleted(Cookie cookie) { + return StringUtils.isBlank(cookie.getValue()) || Objects.isNull(cookie.getValue()); } } diff --git a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/HttpCookieOAuth2AuthorizationRequestRepository.java b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/HttpCookieOAuth2AuthorizationRequestRepository.java index 172982f8b..baa981716 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/HttpCookieOAuth2AuthorizationRequestRepository.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/cookie/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -35,37 +35,27 @@ public void saveAuthorizationRequest( HttpServletRequest request, HttpServletResponse response) { if (authorizationRequest == null) { - CookieUtils.deleteCookie( - request, - response, - OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, - CookieUtils.SameSite.LAX, - null); - CookieUtils.deleteCookie( - request, response, REDIRECT_URL_PARAM_COOKIE_NAME, CookieUtils.SameSite.LAX, null); + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URL_PARAM_COOKIE_NAME); return; } CookieUtils.setCookie( - request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), - cookieExpireSeconds, - CookieUtils.SameSite.LAX, - null); + cookieExpireSeconds); String redirectUrlAfterLogin = request.getParameter(REDIRECT_URL_PARAM_COOKIE_NAME); if (StringUtils.isNotBlank(redirectUrlAfterLogin)) { CookieUtils.setCookie( - request, - response, - REDIRECT_URL_PARAM_COOKIE_NAME, - redirectUrlAfterLogin, - cookieExpireSeconds, - CookieUtils.SameSite.LAX, - null); + response, REDIRECT_URL_PARAM_COOKIE_NAME, redirectUrlAfterLogin, cookieExpireSeconds); } } + @Deprecated + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { + return null; + } + /** * OAuth2AuthorizationRequest 를 쿠키에서 제거함. * @@ -77,25 +67,14 @@ public OAuth2AuthorizationRequest removeAuthorizationRequest( // 쿠키 삭제하기 전에 쿠키 문자열을 객체로 변환 OAuth2AuthorizationRequest authorizationRequest = this.loadAuthorizationRequest(request); - CookieUtils.deleteCookie( - request, - response, - OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, - CookieUtils.SameSite.LAX, - null); + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); return authorizationRequest; } /** redirect_url이 담긴 쿠키는 인증이 완전히 완료된 후에 제거되어야함. */ public void clearCookies(HttpServletRequest request, HttpServletResponse response) { - CookieUtils.deleteCookie( - request, - response, - OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, - CookieUtils.SameSite.LAX, - null); - CookieUtils.deleteCookie( - request, response, REDIRECT_URL_PARAM_COOKIE_NAME, CookieUtils.SameSite.LAX, null); + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URL_PARAM_COOKIE_NAME); } } diff --git a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/member/security/DefaultRoleHierarchy.java b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/member/security/DefaultRoleHierarchy.java index a56e96c48..d0956a32c 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/member/security/DefaultRoleHierarchy.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/domain/oauth2/member/security/DefaultRoleHierarchy.java @@ -29,6 +29,8 @@ public class DefaultRoleHierarchy implements Hierarchical { @Override public RoleHierarchy getHierarchy() { + RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); + Map> roleHierarchyMap = new HashMap<>() { { @@ -60,12 +62,14 @@ public RoleHierarchy getHierarchy() { put(SECRETARY, Arrays.asList(BASIC, DEACTIVATED, NOT_APPROVED, ANONYMOUS)); put(BASIC, Arrays.asList(DEACTIVATED, NOT_APPROVED, ANONYMOUS)); put(DEACTIVATED, Arrays.asList(NOT_APPROVED, ANONYMOUS)); - put(NOT_APPROVED, Collections.singletonList(ANONYMOUS)); - put(SIGNING_UP, Collections.singletonList(ANONYMOUS)); + put(NOT_APPROVED, Arrays.asList(ANONYMOUS)); + put(SIGNING_UP, Arrays.asList(ANONYMOUS)); } }; String roles = RoleHierarchyUtils.roleHierarchyFromMap(roleHierarchyMap); - return RoleHierarchyImpl.fromHierarchy(roles); + roleHierarchy.setHierarchy(roles); + + return roleHierarchy; } } diff --git a/module-auth/src/main/java/com/inhabas/api/auth/domain/token/jwtUtils/JwtTokenUtil.java b/module-auth/src/main/java/com/inhabas/api/auth/domain/token/jwtUtils/JwtTokenUtil.java index b3b55bcf4..8fe1980b8 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/domain/token/jwtUtils/JwtTokenUtil.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/domain/token/jwtUtils/JwtTokenUtil.java @@ -3,11 +3,8 @@ import java.security.Key; import java.util.Date; import java.util.List; -import java.util.UUID; import java.util.stream.Collectors; -import jakarta.annotation.PostConstruct; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,7 +28,6 @@ import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; -import io.jsonwebtoken.security.WeakKeyException; // https://github.com/jwtk/jjwt @Slf4j @@ -44,116 +40,35 @@ public class JwtTokenUtil implements TokenUtil { @Value("${jwt.secretKey}") private String SECRET_KEY; - @Value("${jwt.issuer:inhabas.com}") - private String ISSUER; - - @Value("${jwt.audience:inhabas-client}") - private String AUDIENCE; - - private final Long ACCESS_TOKEN_VALID_MILLIS = 30 * 60 * 1000L; // 0.5 hour - private static final Long REFRESH_TOKEN_VALID_MILLIS = 7 * 24 * 60 * 60 * 1000L; // 7 days + private final Long ACCESS_TOKEN_VALID_MILLISECOND = 30 * 60 * 1000L; // 0.5 hour + private static final Long REFRESH_TOKEN_VALID_MILLI_SECOND = 7 * 24 * 60 * 60 * 1000L; // 7 days private static final String AUTHORITY = "authorities"; private static final String MEMBER_ID = "memberId"; private static final String MEMBER_NAME = "memberName"; private static final String MEMBER_PICTURE = "memberPicture"; - private static final int MIN_HS512_KEY_BYTES = 64; // 512 bits - - // 캐시된 키와 파서 - private volatile Key signingKey; - private volatile JwtParser jwtParser; - - @PostConstruct - public void init() { - validateSecretKeyStrength(); - } - - /** HS512 용 시크릿 키 강도 검증 및 Key/Parser 캐싱. */ - public void validateSecretKeyStrength() { - if (SECRET_KEY == null || SECRET_KEY.trim().isEmpty()) { - throw new IllegalStateException("jwt.secretKey is not configured or blank"); - } - - final byte[] decoded; - try { - decoded = Decoders.BASE64.decode(SECRET_KEY.trim()); - } catch (IllegalArgumentException e) { - throw new IllegalStateException("jwt.secretKey must be Base64-encoded", e); - } - - if (decoded.length < MIN_HS512_KEY_BYTES) { - throw new IllegalStateException( - "Weak jwt.secretKey for HS512: require >= 64 bytes after Base64 decoding, got " - + decoded.length - + " bytes"); - } - - try { - // 키/파서 캐시 생성 (WeakKeyException 등 조기 검출) - Key key = Keys.hmacShaKeyFor(decoded); - this.signingKey = key; - this.jwtParser = - Jwts.parserBuilder() - .setSigningKey(key) - .requireIssuer(ISSUER) - .requireAudience(AUDIENCE) - .build(); - } catch (WeakKeyException e) { - throw new IllegalStateException("Weak jwt.secretKey: " + e.getMessage(), e); - } - } - - private Key getSigningKey() { - Key key = this.signingKey; - if (key != null) { - return key; - } - // lazy-init (테스트 등 부팅 훅 없이 사용되는 경우 대비) - Key newKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY)); - this.signingKey = newKey; - return newKey; - } - - private JwtParser getJwtParser() { - JwtParser parser = this.jwtParser; - if (parser != null) { - return parser; - } - // lazy-init - JwtParser newParser = - Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .requireIssuer(ISSUER) - .requireAudience(AUDIENCE) - .build(); - this.jwtParser = newParser; - return newParser; - } @Override public String createAccessToken(Authentication authentication) { - return createToken(authentication, ACCESS_TOKEN_VALID_MILLIS); + return createToken(authentication, ACCESS_TOKEN_VALID_MILLISECOND); } @Override public String createRefreshToken(Authentication authentication) { - String token = this.createToken(authentication, REFRESH_TOKEN_VALID_MILLIS); + String token = this.createToken(authentication, REFRESH_TOKEN_VALID_MILLI_SECOND); refreshTokenRepository.save(new RefreshToken(token)); return token; } @Override public Long getExpiration() { - // 초 단위로 통일 - return this.ACCESS_TOKEN_VALID_MILLIS / 1000; + return this.ACCESS_TOKEN_VALID_MILLISECOND / 1000; } private String createToken(Authentication authentication, Long expiration) { - if (authentication == null) { - throw new IllegalArgumentException("authentication must not be null"); - } + assert authentication != null; OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo((OAuth2AuthenticationToken) authentication); @@ -168,13 +83,10 @@ private String createToken(Authentication authentication, Long expiration) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); - final Key key = getSigningKey(); + final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY)); return Jwts.builder() .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setIssuer(ISSUER) - .setAudience(AUDIENCE) - .setId(UUID.randomUUID().toString()) .setSubject(uid) .claim(MEMBER_ID, customOAuth2User.getMemberId()) .claim(MEMBER_NAME, customOAuth2User.getMemberName()) @@ -190,7 +102,7 @@ private String createToken(Authentication authentication, Long expiration) { public void validate(String token) { try { - getJwtParser().parseClaimsJws(token); + Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(token); } catch (SecurityException ex) { log.error("Invalid JWT signature"); throw new InvalidTokenException(); @@ -218,11 +130,8 @@ public JwtAuthenticationToken getAuthentication(String token) throws JwtExceptio Claims claims = this.parseClaims(token); - Number memberIdNumber = claims.get(MEMBER_ID, Number.class); - Long memberId = memberIdNumber == null ? null : memberIdNumber.longValue(); - + Long memberId = claims.get(MEMBER_ID, Long.class); List rawAuthorities = claims.get(AUTHORITY, List.class); - if (rawAuthorities == null) rawAuthorities = List.of(); List grantedAuthorities = rawAuthorities.stream() @@ -236,7 +145,7 @@ public JwtAuthenticationToken getAuthentication(String token) throws JwtExceptio /* 토큰 body 에 넣어둔 사용자 정보를 가져옴 * validation 검사를 먼저 꼭 해야함! */ private Claims parseClaims(String token) throws JwtException { - return getJwtParser().parseClaimsJws(token).getBody(); + return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(token).getBody(); } @Override @@ -253,37 +162,14 @@ public TokenDto reissueAccessTokenUsing(String refreshToken) throws InvalidToken private TokenDto createAccessTokenOnly(Claims claims) { Date now = new Date(); - final Key key = getSigningKey(); - - // 화이트리스트 기반으로 클레임 재구성: 필요한 필드만 복사 - String subject = claims.getSubject(); - Number memberIdNumber = claims.get(MEMBER_ID, Number.class); - Long memberId = memberIdNumber == null ? null : memberIdNumber.longValue(); - String memberName = claims.get(MEMBER_NAME, String.class); - String memberPicture = claims.get(MEMBER_PICTURE, String.class); - List rawAuthorities = claims.get(AUTHORITY, List.class); - - List authorities = - rawAuthorities == null - ? List.of() - : rawAuthorities.stream() - .filter(v -> v instanceof String) - .map(v -> (String) v) - .collect(Collectors.toList()); + final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY)); String accessToken = Jwts.builder() .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setIssuer(ISSUER) - .setAudience(AUDIENCE) - .setId(UUID.randomUUID().toString()) - .setSubject(subject) - .claim(MEMBER_ID, memberId) - .claim(MEMBER_NAME, memberName) - .claim(MEMBER_PICTURE, memberPicture) - .claim(AUTHORITY, authorities) + .setClaims(claims) .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_MILLIS)) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_MILLISECOND)) .signWith(key, SignatureAlgorithm.HS512) .compact(); @@ -291,7 +177,7 @@ private TokenDto createAccessTokenOnly(Claims claims) { .grantType("Bearer") .accessToken(accessToken) .refreshToken("") - .accessTokenExpireDate(ACCESS_TOKEN_VALID_MILLIS / 1000) // 초 단위 + .accessTokenExpireDate(ACCESS_TOKEN_VALID_MILLISECOND) .build(); } } diff --git a/module-auth/src/main/java/com/inhabas/api/auth/domain/token/securityFilter/JwtAuthenticationFilter.java b/module-auth/src/main/java/com/inhabas/api/auth/domain/token/securityFilter/JwtAuthenticationFilter.java index e1523de54..9109abef8 100644 --- a/module-auth/src/main/java/com/inhabas/api/auth/domain/token/securityFilter/JwtAuthenticationFilter.java +++ b/module-auth/src/main/java/com/inhabas/api/auth/domain/token/securityFilter/JwtAuthenticationFilter.java @@ -23,7 +23,6 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import com.fasterxml.jackson.databind.ObjectMapper; -import com.inhabas.api.auth.domain.error.ErrorCode; import com.inhabas.api.auth.domain.error.ErrorResponse; import com.inhabas.api.auth.domain.error.authException.CustomAuthException; import com.inhabas.api.auth.domain.token.TokenResolver; @@ -48,7 +47,7 @@ public JwtAuthenticationFilter( @Override public Authentication attemptAuthentication( HttpServletRequest request, HttpServletResponse response) - throws AuthenticationException, IOException { + throws AuthenticationException, IOException, ServletException { final String token = tokenResolver.resolveAccessTokenOrNull(request); @@ -81,27 +80,14 @@ protected void successfulAuthentication( @Override protected void unsuccessfulAuthentication( HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) - throws IOException { + throws IOException, ServletException { SecurityContextHolder.clearContext(); - log.info("Failed to process authentication request: {}", failed.getMessage()); + log.info("Failed to process authentication request", failed); + response.setStatus(((CustomAuthException) failed).getErrorCode().getStatus()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - - if (failed instanceof CustomAuthException) { - response.setStatus(((CustomAuthException) failed).getErrorCode().getStatus()); - try (OutputStream os = response.getOutputStream()) { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.writeValue( - os, ErrorResponse.of(((CustomAuthException) failed).getErrorCode())); - os.flush(); - } - return; - } - - // 일반 인증 실패에 대해서는 401로 응답하고 자세한 정보를 노출하지 않음 - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); try (OutputStream os = response.getOutputStream()) { ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.writeValue(os, ErrorResponse.of(ErrorCode.JWT_INVALID)); + objectMapper.writeValue(os, ErrorResponse.of(((CustomAuthException) failed).getErrorCode())); os.flush(); } } diff --git a/module-auth/src/test/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtilsTest.java b/module-auth/src/test/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtilsTest.java index ec504a0a8..974f1694e 100644 --- a/module-auth/src/test/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtilsTest.java +++ b/module-auth/src/test/java/com/inhabas/api/auth/domain/oauth2/cookie/CookieUtilsTest.java @@ -44,13 +44,12 @@ public void resolveCookieFromRequest() { @Test public void saveCookieToResponse() { // given - MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); String cookieName = "myCookie"; String cookieContents = "hello"; // when - CookieUtils.setCookie(request, response, cookieName, cookieContents, 180); + CookieUtils.setCookie(response, cookieName, cookieContents, 180); // then Cookie resolvedCookie = response.getCookie(cookieName); @@ -98,7 +97,7 @@ public void serializingTest() OAuth2AuthorizationRequest.Builder builder = (OAuth2AuthorizationRequest.Builder) constructor.newInstance(AuthorizationGrantType.AUTHORIZATION_CODE); - OAuth2AuthorizationRequest requestObj = + OAuth2AuthorizationRequest request = builder .authorizationUri("https://kauth.kakao.com/oauth/authorize") .clientId("1234") @@ -110,7 +109,7 @@ public void serializingTest() .build(); // when - String serializedRequest = CookieUtils.serialize(requestObj); + String serializedRequest = CookieUtils.serialize(request); // then assertTrue(Base64.isBase64(serializedRequest)); @@ -155,56 +154,4 @@ public void deserializingTest() // then assertThat(deserializedRequest).usingRecursiveComparison().isEqualTo(originalRequest); } - - @DisplayName("역직렬화 실패 시 null 반환한다.") - @Test - public void deserializeReturnsNullOnInvalidBase64OrPayload() { - // given - Cookie invalid = new Cookie("bad", "not_base64!!"); - - // when - OAuth2AuthorizationRequest result = - CookieUtils.deserialize(invalid, OAuth2AuthorizationRequest.class); - - // then - assertThat(result).isNull(); - } - - @DisplayName("setCookie 시 SameSite=Lax 헤더를 추가한다.") - @Test - public void setCookieAddsSameSiteHeader() { - // given - MockHttpServletRequest request = new MockHttpServletRequest(); - MockHttpServletResponse response = new MockHttpServletResponse(); - - // when - CookieUtils.setCookie(request, response, "s", "v", 60); - - // then - java.util.List setCookies = response.getHeaders("Set-Cookie"); - assertThat(setCookies).isNotEmpty(); - assertThat(String.join(" ", setCookies)).contains("SameSite=Lax"); - } - - @DisplayName("deleteCookie 시 SameSite=Lax, Max-Age=0, HttpOnly를 포함한다.") - @Test - public void deleteCookieAddsAttributesProperly() { - // given - MockHttpServletRequest request = new MockHttpServletRequest(); - MockHttpServletResponse response = new MockHttpServletResponse(); - Cookie cookie = new Cookie("del", "bye"); - cookie.setMaxAge(120); - request.setCookies(cookie); - - // when - CookieUtils.deleteCookie(request, response, "del"); - - // then - java.util.List setCookies = response.getHeaders("Set-Cookie"); - assertThat(setCookies).isNotEmpty(); - String combined = String.join(" ", setCookies); - assertThat(combined).contains("SameSite=Lax"); - assertThat(combined).contains("Max-Age=0"); - assertThat(combined).contains("HttpOnly"); - } } diff --git a/module-auth/src/test/java/com/inhabas/api/auth/domain/token/JwtTokenUtilTest.java b/module-auth/src/test/java/com/inhabas/api/auth/domain/token/JwtTokenUtilTest.java index 06869c878..bf5cee8a8 100644 --- a/module-auth/src/test/java/com/inhabas/api/auth/domain/token/JwtTokenUtilTest.java +++ b/module-auth/src/test/java/com/inhabas/api/auth/domain/token/JwtTokenUtilTest.java @@ -80,7 +80,7 @@ public void createJwtTokenTest() { @Test public void nullAuthenticationTokenTest() { - assertThrows(IllegalArgumentException.class, () -> jwtTokenUtil.createAccessToken(null)); + assertThrows(AssertionError.class, () -> jwtTokenUtil.createAccessToken(null)); } @DisplayName("토큰을 정상적으로 decode") diff --git a/resource-server/src/main/java/com/inhabas/api/config/WebSecurityConfig.java b/resource-server/src/main/java/com/inhabas/api/config/WebSecurityConfig.java index f5b9d36f2..6c15863da 100644 --- a/resource-server/src/main/java/com/inhabas/api/config/WebSecurityConfig.java +++ b/resource-server/src/main/java/com/inhabas/api/config/WebSecurityConfig.java @@ -3,36 +3,29 @@ import static com.inhabas.api.auth.domain.oauth2.member.domain.valueObject.Role.*; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; 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.context.annotation.Profile; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; 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.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; import org.springframework.security.web.util.matcher.AndRequestMatcher; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsUtils; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.inhabas.api.auth.config.AuthBeansConfig; import com.inhabas.api.auth.domain.oauth2.member.security.Hierarchical; @@ -50,10 +43,10 @@ public class WebSecurityConfig { private static final String[] AUTH_WHITELIST_SWAGGER = { - "/swagger-ui/**", "/swagger/**", "/docs/**", "/v3/api-docs/**", "/swagger-resources/**" + "/swagger-ui/**", "/swagger/**", "/docs/**" }; private static final String[] AUTH_WHITELIST_STATIC = { - "/static/css/**", "/static/js/**", "/**/*.ico", "/favicon.ico" + "/static/css/**", "/static/js/**", "*.ico" }; private static final String[] AUTH_WHITELIST_TOKEN = {"/token/**"}; private static final String[] AUTH_WHITELIST_PATH = { @@ -68,10 +61,13 @@ public class WebSecurityConfig { "/club/activity/**", "/club/activities" }; private static final String[] AUTH_WHITELIST_NORMAL_BOARD = {"/board/count"}; + private static final String[] AUTH_WHITELIST_PROJECT_BOARD = {"/project/count"}; + private static final String[] AUTH_WHITELIST_CONTEST_BOARD = {"/contest/count"}; @Configuration + @Order(1) @EnableMethodSecurity(jsr250Enabled = true) @EnableWebSecurity @RequiredArgsConstructor @@ -82,173 +78,135 @@ public static class ApiSecurityForDev { private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final Hierarchical hierarchy; private final JwtTokenUtil jwtTokenUtil; - private final AuthBeansConfig authBeansConfig; private final JwtAuthenticationProvider jwtAuthenticationProvider; - - @Value("${cors.allowed-origins:*}") - private String allowedOriginsProp; + private final AuthBeansConfig authBeansConfig; @Bean - public AuthenticationManager authenticationManager() { - return new ProviderManager(List.of(jwtAuthenticationProvider)); + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder builder = + http.getSharedObject(AuthenticationManagerBuilder.class); + builder.authenticationProvider(jwtAuthenticationProvider); + return builder.build(); } @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - - List origins = - Arrays.stream(allowedOriginsProp.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toList()); - - boolean wildcard = origins.contains("*"); - if (wildcard) { - // 패턴 전체 허용 시, 자격 증명은 허용하지 않음 (보안 권장) - config.setAllowedOriginPatterns(List.of("*")); - config.setAllowCredentials(false); - } else { - config.setAllowedOrigins(origins); - config.setAllowCredentials(true); + public JwtAuthenticationFilter jwtAuthenticationFilter( + AuthenticationManager authenticationManager) { + final List skipPaths = new ArrayList<>(); + + for (String path : AUTH_WHITELIST_CLUB_ACTIVITY) { + skipPaths.add(new CustomRequestMatcher(path, "GET")); } - config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setExposedHeaders(List.of("Authorization", "Content-Disposition")); - config.setMaxAge(3600L); + for (String path : AUTH_WHITELIST_TOKEN) { + skipPaths.add(new CustomRequestMatcher(path, "POST")); + } - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } + final RequestMatcher requestMatcher = new AndRequestMatcher(skipPaths); + final JwtAuthenticationFilter filter = + new JwtAuthenticationFilter( + requestMatcher, jwtTokenUtil, authBeansConfig.tokenResolver()); - @Bean - public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> {}; + filter.setAuthenticationManager(authenticationManager); + return filter; } @Bean @Order(1) - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.anonymous( - anonymous -> anonymous.principal("anonymousUser").authorities("ROLE_" + ANONYMOUS)) - .httpBasic(httpBasic -> httpBasic.disable()) + public SecurityFilterChain apiSecurityFilterChain( + HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.securityMatcher("/**") + .anonymous(anon -> anon.authorities("ROLE_" + ANONYMOUS)) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement( - sessionManagement -> - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) - .headers( - headers -> - headers - .referrerPolicy( - r -> r.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.NO_REFERRER)) - .frameOptions(frame -> frame.sameOrigin())) .exceptionHandling( - exceptionHandling -> - exceptionHandling - .authenticationEntryPoint(jwtAuthenticationEntryPoint) + ex -> + ex.authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler)) .authorizeHttpRequests( authorize -> authorize .requestMatchers(CorsUtils::isPreFlightRequest) .permitAll() - // Swagger 및 공개 경로는 필터 체인은 타되 인가만 면제 - .requestMatchers(pathMatchers(AUTH_WHITELIST_SWAGGER)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_POLICY) .permitAll() - .requestMatchers(pathMatchers(AUTH_WHITELIST_STATIC)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_SIGNUP) .permitAll() - .requestMatchers(pathMatchers(AUTH_WHITELIST_PATH)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_CLUB) .permitAll() - // GET 공개 엔드포인트들 - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_POLICY)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_NORMAL_BOARD) .permitAll() - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_SIGNUP)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_PROJECT_BOARD) .permitAll() - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_CLUB)) + .requestMatchers(HttpMethod.GET, AUTH_WHITELIST_CONTEST_BOARD) .permitAll() - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_NORMAL_BOARD)) + .requestMatchers(AUTH_WHITELIST_SWAGGER) .permitAll() - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_PROJECT_BOARD)) + .requestMatchers(AUTH_WHITELIST_STATIC) .permitAll() - .requestMatchers(methodMatchers(HttpMethod.GET, AUTH_WHITELIST_CONTEST_BOARD)) + .requestMatchers(AUTH_WHITELIST_PATH) .permitAll() - // 나머지 기존 인가 규칙 유지 - .requestMatchers(pathMatchers("/myInfo/requests")) + .requestMatchers("/myInfo/requests") .hasAnyRole(CHIEF.toString(), VICE_CHIEF.toString()) - .requestMatchers(pathMatchers("/myInfo/request/**")) + .requestMatchers("/myInfo/request/**") .hasAnyRole(CHIEF.toString(), VICE_CHIEF.toString()) - .requestMatchers(pathMatchers("/members/executive")) + // 회원 관리 + .requestMatchers("/members/executive") .hasRole(ANONYMOUS.toString()) - .requestMatchers(pathMatchers("/members/hof")) + .requestMatchers("/members/hof") .hasRole(DEACTIVATED.toString()) - .requestMatchers(pathMatchers("/members/approved/role")) + .requestMatchers("/members/approved/role") .hasRole(SECRETARY.toString()) - .requestMatchers(pathMatchers("/members/approved/type")) + .requestMatchers("/members/approved/type") .hasAnyRole(CHIEF.toString(), VICE_CHIEF.toString()) - .requestMatchers(pathMatchers("/members/**", "/member/**")) + .requestMatchers("/members/**", "/member/**") .hasAnyRole(SECRETARY.toString(), EXECUTIVES.toString()) + // 회계내역 .requestMatchers( - pathMatchers( - "/budget/history/**", - "/budget/histories", - "/budget/application/**", - "/budget/applications")) + "/budget/history/**", + "/budget/histories", + "/budget/application/**", + "/budget/applications") .hasRole(DEACTIVATED.toString()) - .requestMatchers(pathMatchers("/lecture/**/status")) + // 강의 + .requestMatchers( + "/lecture/*/status", + "/lecture/*/students/status", + "/lecture/*/student/*/status") .hasRole(EXECUTIVES.toString()) - .requestMatchers(pathMatchers("/lecture/**")) + .requestMatchers("/lecture/**") .hasRole(DEACTIVATED.toString()) - .requestMatchers(methodMatchers(HttpMethod.PUT, "/signUp/schedule")) + // 회원가입 일정 수정 + .requestMatchers(HttpMethod.PUT, "/signUp/schedule") .hasAnyRole(CHIEF.toString(), VICE_CHIEF.toString()) - .requestMatchers(pathMatchers("/signUp/check")) - .hasAnyRole(ANONYMOUS.toString(), SIGNING_UP.toString()) - .requestMatchers(pathMatchers("/signUp/**")) + // 회원가입은 ANONYMOUS 권한은 명시적으로 부여받은 상태에서만 가능 + .requestMatchers("/signUp/check") + .hasRole(ANONYMOUS.toString()) + .requestMatchers("/signUp/**") .hasRole(SIGNING_UP.toString()) - .requestMatchers(pathMatchers("/club/history/**")) + // 동아리 연혁 수정 + .requestMatchers("/club/history/**") .hasRole(EXECUTIVES.toString()) - .requestMatchers(methodMatchers(HttpMethod.PUT, "/policy/**")) + // 정책 수정 + .requestMatchers(HttpMethod.PUT, "/policy/**") .hasAnyRole(CHIEF.toString(), VICE_CHIEF.toString()) - .requestMatchers(pathMatchers("/scholarship/history/**")) + // 장학회 연혁 수정 + .requestMatchers("/scholarship/history/**") .hasRole(SECRETARY.toString()) + // 그 외 .anyRequest() .hasRole(ANONYMOUS.toString())); - http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); - + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - private static RequestMatcher[] pathMatchers(String... patterns) { - return Arrays.stream(patterns).map(AntPathRequestMatcher::new).toArray(RequestMatcher[]::new); - } - - private static RequestMatcher[] methodMatchers(HttpMethod method, String... patterns) { - return Arrays.stream(patterns) - .map(p -> new AntPathRequestMatcher(p, method.name())) - .toArray(RequestMatcher[]::new); - } - @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception { - final List skipPaths = new ArrayList<>(); - - for (String path : AUTH_WHITELIST_CLUB_ACTIVITY) { - skipPaths.add(new CustomRequestMatcher(path, "GET")); - } - - for (String path : AUTH_WHITELIST_TOKEN) { - skipPaths.add(new CustomRequestMatcher(path, "POST")); - } - - final RequestMatcher requestMatcher = new AndRequestMatcher(skipPaths); - final JwtAuthenticationFilter filter = - new JwtAuthenticationFilter( - requestMatcher, jwtTokenUtil, authBeansConfig.tokenResolver()); - filter.setAuthenticationManager(authenticationManager()); - return filter; + public RoleHierarchy roleHierarchy() { + return hierarchy.getHierarchy(); } } } diff --git a/resource-server/src/main/java/com/inhabas/api/domain/menu/repository/MenuRepositoryImpl.java b/resource-server/src/main/java/com/inhabas/api/domain/menu/repository/MenuRepositoryImpl.java index 44cfdb9e7..9e538b0c9 100644 --- a/resource-server/src/main/java/com/inhabas/api/domain/menu/repository/MenuRepositoryImpl.java +++ b/resource-server/src/main/java/com/inhabas/api/domain/menu/repository/MenuRepositoryImpl.java @@ -1,6 +1,7 @@ package com.inhabas.api.domain.menu.repository; import static com.inhabas.api.domain.menu.domain.QMenu.menu; +import static com.inhabas.api.domain.menu.domain.QMenuGroup.menuGroup; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -12,6 +13,7 @@ import lombok.RequiredArgsConstructor; import com.inhabas.api.domain.menu.domain.Menu; +import com.inhabas.api.domain.menu.domain.MenuGroup; import com.inhabas.api.domain.menu.domain.valueObject.MenuId; import com.inhabas.api.domain.menu.dto.MenuDto; import com.inhabas.api.domain.menu.dto.MenuGroupDto; @@ -23,35 +25,29 @@ public class MenuRepositoryImpl implements MenuRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; public List findAllMenuByMenuGroup() { - // Hibernate 6 호환: transform/ScrollableResults 없이 한 번에 조회 후 Java에서 그룹핑 List menus = jpaQueryFactory .selectFrom(menu) - .join(menu.menuGroup) + .leftJoin(menu.menuGroup, menuGroup) .fetchJoin() .orderBy(menu.menuGroup.id.asc(), menu.priority.asc()) .fetch(); - // 그룹 이름 캐싱 (id -> name), 첫 등장 값 유지 - Map groupNames = new LinkedHashMap<>(); - menus.forEach( - m -> groupNames.putIfAbsent(m.getMenuGroup().getId(), m.getMenuGroup().getName())); - - // 메뉴를 그룹 ID 별로, 입력 순서 유지하며 모음 - Map> grouped = - menus.stream() - .collect( - Collectors.groupingBy( - m -> m.getMenuGroup().getId(), - LinkedHashMap::new, - Collectors.mapping(MenuDto::convert, Collectors.toList()))); - - List result = new ArrayList<>(grouped.size()); - grouped.forEach( - (groupId, menuDtos) -> - result.add(new MenuGroupDto(groupId, groupNames.get(groupId), menuDtos))); - - return result; + // 메뉴 그룹별로 그룹화 + Map> groupedMenus = new LinkedHashMap<>(); + for (Menu m : menus) { + MenuGroup group = m.getMenuGroup(); + groupedMenus.putIfAbsent(group, new ArrayList<>()); + groupedMenus.get(group).add(MenuDto.convert(m)); + } + + // MenuGroupDto로 변환 + return groupedMenus.entrySet().stream() + .map( + entry -> + new MenuGroupDto( + entry.getKey().getId(), entry.getKey().getName(), entry.getValue())) + .collect(Collectors.toList()); } @Override diff --git a/resource-server/src/main/java/com/inhabas/api/web/ContestBoardController.java b/resource-server/src/main/java/com/inhabas/api/web/ContestBoardController.java index 1b319ccce..7f6674c13 100644 --- a/resource-server/src/main/java/com/inhabas/api/web/ContestBoardController.java +++ b/resource-server/src/main/java/com/inhabas/api/web/ContestBoardController.java @@ -23,7 +23,6 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import com.inhabas.api.auth.domain.error.ErrorResponse; -import com.inhabas.api.auth.domain.error.businessException.InvalidInputException; import com.inhabas.api.domain.board.dto.BoardCountDto; import com.inhabas.api.domain.board.repository.BaseBoardRepository; import com.inhabas.api.domain.contest.domain.ContestType; @@ -245,12 +244,6 @@ public ResponseEntity updateContestBoard( return ResponseEntity.noContent().build(); } - // 잘못된 경로(boardsId 누락)로 들어오는 요청을 400으로 처리 - @PostMapping(path = "/contest/{contestType}/") - public ResponseEntity updateContestBoardInvalidPath(@PathVariable ContestType contestType) { - throw new InvalidInputException(); - } - @Operation(summary = "공모전 게시글 삭제") @DeleteMapping("contest/{contestType}/{boardId}") @PreAuthorize("@boardSecurityChecker.boardWriterOnly(#boardId) or hasRole('VICE_CHIEF')") diff --git a/resource-server/src/test/java/com/inhabas/api/domain/questionnaire/repository/QuestionnaireRepositoryTest.java b/resource-server/src/test/java/com/inhabas/api/domain/questionnaire/repository/QuestionnaireRepositoryTest.java index 578531a8a..535771f7e 100644 --- a/resource-server/src/test/java/com/inhabas/api/domain/questionnaire/repository/QuestionnaireRepositoryTest.java +++ b/resource-server/src/test/java/com/inhabas/api/domain/questionnaire/repository/QuestionnaireRepositoryTest.java @@ -27,11 +27,11 @@ public void countByIdIn() { ArrayList questionnaireInDatabase = new ArrayList<>() { { - add(new Questionnaire(1L, "지원동기 및 목표를 기술해주세요.")); - add(new Questionnaire(2L, "프로그래밍 관련 언어를 다루어 본 적이 있다면 적어주세요.")); - add(new Questionnaire(3L, "빅데이터 관련 활동 혹은 공모전 관련 경험이 있다면 적어주세요.")); - add(new Questionnaire(4L, "추후 희망하는 진로가 무엇이며, 동아리 활동이 진로에 어떠한 영향을 줄 것이라고 생각하나요?")); - add(new Questionnaire(5L, "어떤 경로로 IBAS를 알게 되셨나요?")); + add(new Questionnaire(null, "지원동기 및 목표를 기술해주세요.")); + add(new Questionnaire(null, "프로그래밍 관련 언어를 다루어 본 적이 있다면 적어주세요.")); + add(new Questionnaire(null, "빅데이터 관련 활동 혹은 공모전 관련 경험이 있다면 적어주세요.")); + add(new Questionnaire(null, "추후 희망하는 진로가 무엇이며, 동아리 활동이 진로에 어떠한 영향을 줄 것이라고 생각하나요?")); + add(new Questionnaire(null, "어떤 경로로 IBAS를 알게 되셨나요?")); } }; questionnaireRepository.saveAll(questionnaireInDatabase); diff --git a/resource-server/src/test/java/com/inhabas/api/domain/scholarship/repository/ScholarshipHistoryRepositoryTest.java b/resource-server/src/test/java/com/inhabas/api/domain/scholarship/repository/ScholarshipHistoryRepositoryTest.java index 936c3be2b..1c1a4168d 100644 --- a/resource-server/src/test/java/com/inhabas/api/domain/scholarship/repository/ScholarshipHistoryRepositoryTest.java +++ b/resource-server/src/test/java/com/inhabas/api/domain/scholarship/repository/ScholarshipHistoryRepositoryTest.java @@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.test.util.ReflectionTestUtils; import com.inhabas.api.auth.domain.oauth2.member.domain.entity.Member; import com.inhabas.api.auth.domain.oauth2.member.repository.MemberRepository; @@ -31,16 +30,17 @@ void getYearlyData() { Member writer = memberRepository.save(MemberTest.chiefMember()); ScholarshipHistory scholarshipHistory = new ScholarshipHistory(writer, "title", LocalDateTime.now()); - ReflectionTestUtils.setField(scholarshipHistory, "id", 1L); ScholarshipHistory savedScholarshipHistory = scholarshipHistoryRepository.save(scholarshipHistory); Data data = - new Data(1L, savedScholarshipHistory.getTitle(), savedScholarshipHistory.getDateHistory()); + new Data( + savedScholarshipHistory.getId(), + savedScholarshipHistory.getTitle(), + savedScholarshipHistory.getDateHistory()); List savedData = List.of(new YearlyData(savedScholarshipHistory.getDateHistory().getYear(), List.of(data))); // when - scholarshipHistoryRepository.save(scholarshipHistory); List yearlyData = scholarshipHistoryRepository.getYearlyData(); // then diff --git a/resource-server/src/test/java/com/inhabas/api/domain/signUp/SignUpIntegrationTest.java b/resource-server/src/test/java/com/inhabas/api/domain/signUp/SignUpIntegrationTest.java index 74af3571a..fc32492d1 100644 --- a/resource-server/src/test/java/com/inhabas/api/domain/signUp/SignUpIntegrationTest.java +++ b/resource-server/src/test/java/com/inhabas/api/domain/signUp/SignUpIntegrationTest.java @@ -348,11 +348,11 @@ public static RequestPostProcessor accessToken(String accessToken) { private void 면접질문_설정() { questionnaireRepository.saveAll( Arrays.asList( - new Questionnaire(1L, "지원동기 및 목표를 기술해주세요."), - new Questionnaire(2L, "프로그래밍 관련 언어를 다루어 본 적이 있다면 적어주세요."), - new Questionnaire(3L, "빅데이터 관련 활동 혹은 공모전 관련 경험이 있다면 적어주세요."), - new Questionnaire(4L, "추후 희망하는 진로가 무엇이며, 동아리 활동이 진로에 어떠한 영향을 줄 것이라고 생각하나요?"), - new Questionnaire(5L, "어떤 경로로 IBAS를 알게 되셨나요?"))); + new Questionnaire(null, "지원동기 및 목표를 기술해주세요."), + new Questionnaire(null, "프로그래밍 관련 언어를 다루어 본 적이 있다면 적어주세요."), + new Questionnaire(null, "빅데이터 관련 활동 혹은 공모전 관련 경험이 있다면 적어주세요."), + new Questionnaire(null, "추후 희망하는 진로가 무엇이며, 동아리 활동이 진로에 어떠한 영향을 줄 것이라고 생각하나요?"), + new Questionnaire(null, "어떤 경로로 IBAS를 알게 되셨나요?"))); } private void 전공정보_설정() { diff --git a/resource-server/src/test/java/com/inhabas/api/domain/signUp/repository/AnswerRepositoryTest.java b/resource-server/src/test/java/com/inhabas/api/domain/signUp/repository/AnswerRepositoryTest.java index 5a4ce9097..cafe1d452 100644 --- a/resource-server/src/test/java/com/inhabas/api/domain/signUp/repository/AnswerRepositoryTest.java +++ b/resource-server/src/test/java/com/inhabas/api/domain/signUp/repository/AnswerRepositoryTest.java @@ -33,8 +33,8 @@ class AnswerRepositoryTest { void findByMember_Id() { // given Member member = memberRepository.save(MemberTest.signingUpMember1()); - Questionnaire questionnaire = new Questionnaire(1L, "hello"); - questionnaire = questionnaireRepository.saveAndFlush(questionnaire); + Questionnaire questionnaire = new Questionnaire(null, "hello"); + questionnaireRepository.save(questionnaire); String content = "Ok... bye"; Answer answer = new Answer(member, questionnaire, content); diff --git a/resource-server/src/test/java/com/inhabas/api/web/ContestBoardControllerTest.java b/resource-server/src/test/java/com/inhabas/api/web/ContestBoardControllerTest.java index 1721c9fb7..24afefe4c 100644 --- a/resource-server/src/test/java/com/inhabas/api/web/ContestBoardControllerTest.java +++ b/resource-server/src/test/java/com/inhabas/api/web/ContestBoardControllerTest.java @@ -316,7 +316,7 @@ void updateBoard_Invalid_Input() throws Exception { // when String response = mvc.perform( - post("/contest/contest/") + post("/contest/contest/1") .contentType(MediaType.APPLICATION_JSON) .content(jsonOf(saveContestBoardDto))) .andExpect(status().isBadRequest()) diff --git a/resource-server/src/test/java/com/inhabas/api/web/MenuControllerTest.java b/resource-server/src/test/java/com/inhabas/api/web/MenuControllerTest.java index c2e4fb67f..f61d8134d 100644 --- a/resource-server/src/test/java/com/inhabas/api/web/MenuControllerTest.java +++ b/resource-server/src/test/java/com/inhabas/api/web/MenuControllerTest.java @@ -49,7 +49,7 @@ public void getTotalMenuInfoTest() throws Exception { .andExpect( content() .string( - "[{\"id\":1,\"groupName\":\"IBAS\",\"menuList\":[{\"menuId\":6,\"priority\":1,\"name\":\"ë\u008F\u0099ì\u0095\u0084리 ì\u0086\u008Cê°\u009C\",\"type\":\"INTRODUCE\",\"description\":\"\"}]}]")) + "[{\"id\":1,\"groupName\":\"IBAS\",\"menuList\":[{\"menuId\":6,\"priority\":1,\"name\":\"동아리 소개\",\"type\":\"INTRODUCE\",\"description\":\"\"}]}]")) .andReturn(); then(menuService).should(times(1)).getAllMenuInfo(); @@ -66,7 +66,7 @@ public void getMenuInfoByIdTest() throws Exception { .andExpect( content() .string( - "{\"menuId\":6,\"priority\":1,\"name\":\"ê³µì§\u0080ì\u0082¬í\u0095\u00AD\",\"type\":\"LIST\",\"description\":\"\"}")) + "{\"menuId\":6,\"priority\":1,\"name\":\"공지사항\",\"type\":\"LIST\",\"description\":\"\"}")) .andExpect(status().isOk()) .andReturn(); diff --git a/resource-server/src/test/java/com/inhabas/testAnnotataion/CustomSpringBootTest.java b/resource-server/src/test/java/com/inhabas/testAnnotataion/CustomSpringBootTest.java index 61205f371..e203b8953 100644 --- a/resource-server/src/test/java/com/inhabas/testAnnotataion/CustomSpringBootTest.java +++ b/resource-server/src/test/java/com/inhabas/testAnnotataion/CustomSpringBootTest.java @@ -5,11 +5,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import jakarta.transaction.Transactional; + import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.annotation.AliasFor; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) diff --git a/resource-server/src/test/java/com/inhabas/testAnnotataion/NoSecureWebMvcTest.java b/resource-server/src/test/java/com/inhabas/testAnnotataion/NoSecureWebMvcTest.java index 34b43e623..6c51e88d1 100644 --- a/resource-server/src/test/java/com/inhabas/testAnnotataion/NoSecureWebMvcTest.java +++ b/resource-server/src/test/java/com/inhabas/testAnnotataion/NoSecureWebMvcTest.java @@ -5,7 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -16,7 +16,7 @@ /** * WebMvcTest(excludeAutoConfiguration = {SecurityAutoConfiguration.class, - * OAuth2ClientAutoConfiguration.class}) , default security filter 를 사용하지 않음. 테스트 설정 파일에서 + * OAuth2ClientWebSecurityAutoConfiguration.class}) , default security filter 를 사용하지 않음. 테스트 설정 파일에서 * OAuth2Client 정보를 읽어들이지 않음. * * @see DefaultWebMvcTest @@ -27,7 +27,7 @@ @WebMvcTest( excludeAutoConfiguration = { SecurityAutoConfiguration.class, - OAuth2ClientAutoConfiguration.class + OAuth2ClientWebSecurityAutoConfiguration.class }) // disable default spring-security configuration @Import(InterceptorConfigMockBean.class) public @interface NoSecureWebMvcTest {