diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml index 3714e2928..690fb88c4 100644 --- a/.github/workflows/develop_build_deploy.yml +++ b/.github/workflows/develop_build_deploy.yml @@ -35,6 +35,10 @@ jobs: - name: Run chmod to make gradlew executable run: chmod +x ./gradlew + # Redis 컨테이너 실행 + - name: Start containers + run: docker-compose -f ./docker-compose-test.yaml up -d + # Gradle 빌드 - name: Build with Gradle id: gradle diff --git a/.github/workflows/pull_request_gradle_build.yml b/.github/workflows/pull_request_gradle_build.yml index 1dda5e788..5d65ad446 100644 --- a/.github/workflows/pull_request_gradle_build.yml +++ b/.github/workflows/pull_request_gradle_build.yml @@ -13,7 +13,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: JDK 설치 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 @@ -21,6 +21,10 @@ jobs: - name: gradlew 권한 부여 run: chmod +x ./gradlew + # Redis 컨테이너 실행 + - name: Start containers + run: docker-compose -f ./docker-compose-test.yaml up -d + - name: Gradle Build uses: gradle/gradle-build-action@v2 with: diff --git a/.gitignore b/.gitignore index 17981519d..411f64aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### ETC ### .DS_Store + +### Secrets ### +.env diff --git a/build.gradle b/build.gradle index 5a7430182..0ec976e9a 100644 --- a/build.gradle +++ b/build.gradle @@ -29,8 +29,6 @@ ext { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' @@ -38,8 +36,19 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } tasks.named('test') { diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml new file mode 100644 index 000000000..404a772ba --- /dev/null +++ b/docker-compose-test.yaml @@ -0,0 +1,9 @@ +version: "3.8" + +services: + redis: + image: "redis:alpine" + ports: + - "6379:6379" + environment: + - TZ=Asia/Seoul diff --git a/docker-compose.yml b/docker-compose.yml index 8c982a665..41605f3fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,11 @@ services: - .env environment: - TZ=Asia/Seoul + redis: + image: "redis:alpine" + container_name: redis + ports: + - "6379:6379" + environment: + - TZ=Asia/Seoul + network_mode: host diff --git a/src/main/java/com/gdschongik/gdsc/common/config/WebSecurityConfig.java b/src/main/java/com/gdschongik/gdsc/common/config/WebSecurityConfig.java deleted file mode 100644 index 44237a225..000000000 --- a/src/main/java/com/gdschongik/gdsc/common/config/WebSecurityConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.gdschongik.gdsc.common.config; - -import org.springframework.boot.autoconfigure.security.servlet.PathRequest; -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.annotation.web.configurers.HeadersConfigurer; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class WebSecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { - return httpSecurity - .csrf(AbstractHttpConfigurer::disable) - .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - .authorizeHttpRequests(authorize -> - authorize.requestMatchers(PathRequest.toH2Console()).permitAll()) - .build(); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java b/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java new file mode 100644 index 000000000..c5877110a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/application/JwtService.java @@ -0,0 +1,96 @@ +package com.gdschongik.gdsc.domain.auth.application; + +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; + +import com.gdschongik.gdsc.domain.auth.dao.RefreshTokenRepository; +import com.gdschongik.gdsc.domain.auth.domain.RefreshToken; +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JwtService { + + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + + public AccessTokenDto createAccessToken(Long memberId, MemberRole memberRole) { + return jwtUtil.generateAccessToken(memberId, memberRole); + } + + public RefreshTokenDto createRefreshToken(Long memberId) { + RefreshTokenDto refreshTokenDto = jwtUtil.generateRefreshToken(memberId); + saveRefreshTokenToRedis(refreshTokenDto); + return refreshTokenDto; + } + + private void saveRefreshTokenToRedis(RefreshTokenDto refreshTokenDto) { + RefreshToken refreshToken = RefreshToken.builder() + .memberId(refreshTokenDto.memberId()) + .token(refreshTokenDto.tokenValue()) + .ttl(refreshTokenDto.ttl()) + .build(); + refreshTokenRepository.save(refreshToken); + } + + public AccessTokenDto retrieveAccessToken(String accessTokenValue) { + try { + return jwtUtil.parseAccessToken(accessTokenValue); + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto retrieveRefreshToken(String refreshTokenValue) { + RefreshTokenDto refreshTokenDto = parseRefreshToken(refreshTokenValue); + + if (refreshTokenDto == null) { + return null; + } + + // 파싱된 DTO와 일치하는 토큰이 Redis에 저장되어 있는지 확인 + Optional refreshToken = getRefreshTokenFromRedis(refreshTokenDto.memberId()); + + // Redis에 토큰이 존재하고, 쿠키의 토큰과 값이 일치하면 DTO 반환 + if (refreshToken.isPresent() + && refreshTokenDto.tokenValue().equals(refreshToken.get().getToken())) { + return refreshTokenDto; + } + + // Redis에 토큰이 존재하지 않거나, 쿠키의 토큰과 값이 일치하지 않으면 null 반환 + return null; + } + + private Optional getRefreshTokenFromRedis(Long memberId) { + // TODO: CustomException으로 바꾸기 + return refreshTokenRepository.findByMemberId(memberId); + } + + private RefreshTokenDto parseRefreshToken(String refreshTokenValue) { + try { + return jwtUtil.parseRefreshToken(refreshTokenValue); + } catch (Exception e) { + return null; + } + } + + public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) { + // AT가 만료된 경우 AT를 재발급, 만료되지 않은 경우 null 반환 + try { + jwtUtil.parseAccessToken(accessTokenValue); + return null; + } catch (ExpiredJwtException e) { + Long memberId = Long.parseLong(e.getClaims().getSubject()); + MemberRole memberRole = MemberRole.valueOf(e.getClaims().get(TOKEN_ROLE_NAME, String.class)); + return createAccessToken(memberId, memberRole); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/dao/RefreshTokenRepository.java b/src/main/java/com/gdschongik/gdsc/domain/auth/dao/RefreshTokenRepository.java new file mode 100644 index 000000000..0d81979e7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/dao/RefreshTokenRepository.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.auth.dao; + +import com.gdschongik.gdsc.domain.auth.domain.RefreshToken; +import java.util.Optional; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByMemberId(Long aLong); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/domain/RefreshToken.java b/src/main/java/com/gdschongik/gdsc/domain/auth/domain/RefreshToken.java new file mode 100644 index 000000000..83dfcb61e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/domain/RefreshToken.java @@ -0,0 +1,27 @@ +package com.gdschongik.gdsc.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id + private Long memberId; + + private String token; + + @TimeToLive + private long ttl; + + @Builder + public RefreshToken(Long memberId, String token, long ttl) { + this.memberId = memberId; + this.token = token; + this.ttl = ttl; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java new file mode 100644 index 000000000..503ab11b0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/AccessTokenDto.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.auth.dto; + +import com.gdschongik.gdsc.domain.member.domain.MemberRole; + +public record AccessTokenDto(Long memberId, MemberRole memberRole, String tokenValue) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/auth/dto/RefreshTokenDto.java b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/RefreshTokenDto.java new file mode 100644 index 000000000..43124d2a0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/auth/dto/RefreshTokenDto.java @@ -0,0 +1,3 @@ +package com.gdschongik.gdsc.domain.auth.dto; + +public record RefreshTokenDto(Long memberId, String tokenValue, Long ttl) {} diff --git a/src/main/java/com/gdschongik/gdsc/common/model/BaseTimeEntity.java b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java similarity index 92% rename from src/main/java/com/gdschongik/gdsc/common/model/BaseTimeEntity.java rename to src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java index 248355fa7..08c0a9d9a 100644 --- a/src/main/java/com/gdschongik/gdsc/common/model/BaseTimeEntity.java +++ b/src/main/java/com/gdschongik/gdsc/domain/common/model/BaseTimeEntity.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.common.model; +package com.gdschongik.gdsc.domain.common.model; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java new file mode 100644 index 000000000..bee57ef79 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.domain.member.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByOauthId(String oauthId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java index 4f107ad56..ce5375d50 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java @@ -1,6 +1,6 @@ package com.gdschongik.gdsc.domain.member.domain; -import com.gdschongik.gdsc.common.model.BaseTimeEntity; +import com.gdschongik.gdsc.domain.common.model.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java new file mode 100644 index 000000000..04a4ef40d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/JwtConstant.java @@ -0,0 +1,18 @@ +package com.gdschongik.gdsc.global.common.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtConstant { + ACCESS_TOKEN(Constants.ACCESS_TOKEN_COOKIE_NAME), + REFRESH_TOKEN(Constants.REFRESH_TOKEN_COOKIE_NAME); + + private final String cookieName; + + private static class Constants { + public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java new file mode 100644 index 000000000..412093f8b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class SecurityConstant { + + public static final String REGISTRATION_REQUIRED_HEADER = "Registration-Required"; + public static final String TOKEN_ROLE_NAME = "role"; + + private SecurityConstant() {} +} diff --git a/src/main/java/com/gdschongik/gdsc/common/config/JpaConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java similarity index 82% rename from src/main/java/com/gdschongik/gdsc/common/config/JpaConfig.java rename to src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java index 0e4f5a40f..eb9a05b74 100644 --- a/src/main/java/com/gdschongik/gdsc/common/config/JpaConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/JpaConfig.java @@ -1,4 +1,4 @@ -package com.gdschongik.gdsc.common.config; +package com.gdschongik.gdsc.global.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java new file mode 100644 index 000000000..9b6476cca --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.global.config; + +import com.gdschongik.gdsc.global.property.JwtProperty; +import com.gdschongik.gdsc.global.property.RedisProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({JwtProperty.class, RedisProperty.class}) +@Configuration +public class PropertyConfig {} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/RedisConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/RedisConfig.java new file mode 100644 index 000000000..35233228d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/RedisConfig.java @@ -0,0 +1,35 @@ +package com.gdschongik.gdsc.global.config; + +import com.gdschongik.gdsc.global.property.RedisProperty; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +@RequiredArgsConstructor +@Configuration +public class RedisConfig { + + private final RedisProperty redisProperty; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = + new RedisStandaloneConfiguration(redisProperty.getHost(), redisProperty.getPort()); + + if (!redisProperty.getPassword().isBlank()) { + redisConfig.setPassword(redisProperty.getPassword()); + } + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .shutdownTimeout(Duration.ZERO) + .build(); + + return new LettuceConnectionFactory(redisConfig, clientConfig); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java new file mode 100644 index 000000000..d6dea291b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java @@ -0,0 +1,62 @@ +package com.gdschongik.gdsc.global.config; + +import static org.springframework.security.config.Customizer.*; + +import com.gdschongik.gdsc.domain.auth.application.JwtService; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.global.security.CustomSuccessHandler; +import com.gdschongik.gdsc.global.security.CustomUserService; +import com.gdschongik.gdsc.global.security.JwtFilter; +import com.gdschongik.gdsc.global.util.CookieUtil; +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.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final MemberRepository memberRepository; + private final JwtService jwtService; + private final CookieUtil cookieUtil; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .cors(withDefaults()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + httpSecurity.oauth2Login( + oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo.userService(customUserService(memberRepository))) + .successHandler(customSuccessHandler(jwtService, cookieUtil))); + + httpSecurity.addFilterBefore(jwtFilter(jwtService, cookieUtil), UsernamePasswordAuthenticationFilter.class); + + return httpSecurity.build(); + } + + @Bean + public CustomUserService customUserService(MemberRepository memberRepository) { + return new CustomUserService(memberRepository); + } + + @Bean + public CustomSuccessHandler customSuccessHandler(JwtService jwtService, CookieUtil cookieUtil) { + return new CustomSuccessHandler(jwtService, cookieUtil); + } + + @Bean + public JwtFilter jwtFilter(JwtService jwtService, CookieUtil cookieUtil) { + return new JwtFilter(jwtService, cookieUtil); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/property/JwtProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/JwtProperty.java new file mode 100644 index 000000000..cbc098b00 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/JwtProperty.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.global.property; + +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "jwt") +public class JwtProperty { + + private final Map token; + private final String issuer; + + public record TokenProperty(String secret, Long expirationTime) { + public Long expirationMilliTime() { + return expirationTime * 1000; + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/property/RedisProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/RedisProperty.java new file mode 100644 index 000000000..beb116e12 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/RedisProperty.java @@ -0,0 +1,15 @@ +package com.gdschongik.gdsc.global.property; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "spring.data.redis") +public class RedisProperty { + + private final String host; + private final int port; + private final String password; +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java new file mode 100644 index 000000000..756fa5e8f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2User.java @@ -0,0 +1,24 @@ +package com.gdschongik.gdsc.global.security; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import lombok.Getter; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private final Long memberId; + private final MemberRole memberRole; + + public CustomOAuth2User(OAuth2User oAuth2User, Member member) { + super(oAuth2User.getAuthorities(), oAuth2User.getAttributes(), oAuth2User.getName()); + this.memberId = member.getId(); + this.memberRole = member.getRole(); + } + + public boolean isGuest() { + return memberRole == MemberRole.GUEST; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java new file mode 100644 index 000000000..9a4f4e7b6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java @@ -0,0 +1,40 @@ +package com.gdschongik.gdsc.global.security; + +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; + +import com.gdschongik.gdsc.domain.auth.application.JwtService; +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.global.util.CookieUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +@Slf4j +@RequiredArgsConstructor +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtService jwtService; + private final CookieUtil cookieUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws ServletException { + + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + // 게스트 유저이면 회원가입 필요하므로 헤더 설정 + response.setHeader(REGISTRATION_REQUIRED_HEADER, oAuth2User.isGuest() ? "true" : "false"); + + // 토큰 생성 후 쿠키에 저장 + AccessTokenDto accessTokenDto = + jwtService.createAccessToken(oAuth2User.getMemberId(), oAuth2User.getMemberRole()); + RefreshTokenDto refreshTokenDto = jwtService.createRefreshToken(oAuth2User.getMemberId()); + cookieUtil.addTokenCookies(response, accessTokenDto.tokenValue(), refreshTokenDto.tokenValue()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java new file mode 100644 index 000000000..5b5a08d68 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomUserService.java @@ -0,0 +1,33 @@ +package com.gdschongik.gdsc.global.security; + +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Slf4j +@RequiredArgsConstructor +public class CustomUserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + Member member = fetchOrCreate(oAuth2User); + return new CustomOAuth2User(oAuth2User, member); + } + + private Member fetchOrCreate(OAuth2User oAuth2User) { + return memberRepository.findByOauthId(oAuth2User.getName()).orElseGet(() -> registerMember(oAuth2User)); + } + + private Member registerMember(OAuth2User oAuth2User) { + Member guest = Member.createGuestMember(oAuth2User.getName()); + return memberRepository.save(guest); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java new file mode 100644 index 000000000..011dc9ee6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/JwtFilter.java @@ -0,0 +1,80 @@ +package com.gdschongik.gdsc.global.security; + +import com.gdschongik.gdsc.domain.auth.application.JwtService; +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import com.gdschongik.gdsc.global.util.CookieUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String accessTokenValue = extractTokenValue(JwtConstant.ACCESS_TOKEN, request); + String refreshTokenValue = extractTokenValue(JwtConstant.REFRESH_TOKEN, request); + + // AT와 RT 중 하나라도 없으면 실패 + if (accessTokenValue == null || refreshTokenValue == null) { + filterChain.doFilter(request, response); + return; + } + + AccessTokenDto accessTokenDto = jwtService.retrieveAccessToken(accessTokenValue); + + // AT가 유효하면 통과 + if (accessTokenDto != null) { + setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + filterChain.doFilter(request, response); + return; + } + + Optional reissueAccessToken = + Optional.ofNullable(jwtService.reissueAccessTokenIfExpired(refreshTokenValue)); + RefreshTokenDto refreshTokenDto = jwtService.retrieveRefreshToken(refreshTokenValue); + + // AT가 만료되었고, RT가 유효하면 AT, RT 재발급 + if (reissueAccessToken.isPresent() && refreshTokenDto != null) { + AccessTokenDto accessToken = reissueAccessToken.get(); + RefreshTokenDto refreshToken = jwtService.createRefreshToken(refreshTokenDto.memberId()); + cookieUtil.addTokenCookies(response, accessToken.tokenValue(), refreshToken.tokenValue()); + setAuthenticationToContext(accessToken.memberId(), accessToken.memberRole()); + } + + // AT, RT 둘 다 만료되었으면 실패 + filterChain.doFilter(request, response); + } + + private String extractTokenValue(JwtConstant jwtConstant, HttpServletRequest request) { + return Optional.ofNullable(WebUtils.getCookie(request, jwtConstant.getCookieName())) + .map(Cookie::getValue) + .orElse(null); + } + + private void setAuthenticationToContext(Long memberId, MemberRole memberRole) { + UserDetails userDetails = new PrincipalDetails(memberId, memberRole); + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java b/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java new file mode 100644 index 000000000..80427cbb5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/PrincipalDetails.java @@ -0,0 +1,51 @@ +package com.gdschongik.gdsc.global.security; + +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import java.util.Collection; +import java.util.Collections; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final Long memberId; + private final MemberRole memberRole; + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority(memberRole.getValue())); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return memberId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java new file mode 100644 index 000000000..cffa1cfda --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java @@ -0,0 +1,47 @@ +package com.gdschongik.gdsc.global.util; + +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import com.gdschongik.gdsc.global.property.JwtProperty; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final JwtProperty jwtProperty; + + public void addTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { + HttpHeaders headers = generateTokenCookies(accessToken, refreshToken); + headers.forEach((key, value) -> response.addHeader(key, value.get(0))); + } + + private HttpHeaders generateTokenCookies(String accessToken, String refreshToken) { + // TODO: Prod profile일 때는 Strict, 아니면 None으로 설정 + String sameSite = "None"; + + ResponseCookie accessTokenCookie = + generateCookie(JwtConstant.ACCESS_TOKEN.getCookieName(), accessToken, sameSite); + + ResponseCookie refreshTokenCookie = + generateCookie(JwtConstant.REFRESH_TOKEN.getCookieName(), refreshToken, sameSite); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + private ResponseCookie generateCookie(String cookieName, String tokenValue, String sameSite) { + return ResponseCookie.from(cookieName, tokenValue) + .path("/") + .secure(true) + .sameSite(sameSite) + .httpOnly(false) + .build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java new file mode 100644 index 000000000..bc8dacdc6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/JwtUtil.java @@ -0,0 +1,113 @@ +package com.gdschongik.gdsc.global.util; + +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; + +import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto; +import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.global.common.constant.JwtConstant; +import com.gdschongik.gdsc.global.property.JwtProperty; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * JWT 토큰을 생성하고 파싱하는 유틸 클래스
+ * 토큰 저장, 재발급 등의 기능은 {@link com.gdschongik.gdsc.domain.auth.application.JwtService}에서 담당한다.
+ * JWT 토큰을 사용하기 위해서는 해당 클래스를 사용해야 하며, JwtUtil을 직접 사용하지 않도록 주의한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtUtil { + + private final JwtProperty jwtProperty; + + public AccessTokenDto generateAccessToken(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = new Date(issuedAt.getTime() + + jwtProperty.getToken().get(JwtConstant.ACCESS_TOKEN).expirationMilliTime()); + Key key = getKey(JwtConstant.ACCESS_TOKEN); + + String tokenValue = buildToken(memberId, memberRole, issuedAt, expiredAt, key); + return new AccessTokenDto(memberId, memberRole, tokenValue); + } + + public RefreshTokenDto generateRefreshToken(Long memberId) { + Date issuedAt = new Date(); + JwtProperty.TokenProperty refreshTokenProperty = jwtProperty.getToken().get(JwtConstant.REFRESH_TOKEN); + Date expiredAt = new Date(issuedAt.getTime() + refreshTokenProperty.expirationMilliTime()); + Key key = getKey(JwtConstant.REFRESH_TOKEN); + + String tokenValue = buildToken(memberId, null, issuedAt, expiredAt, key); + return new RefreshTokenDto(memberId, tokenValue, refreshTokenProperty.expirationTime()); + } + + private String buildToken(Long memberId, MemberRole memberRole, Date issuedAt, Date expiredAt, Key key) { + + JwtBuilder jwtBuilder = Jwts.builder() + .setIssuer(jwtProperty.getIssuer()) + .setSubject(memberId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key); + + if (memberRole != null) { + jwtBuilder.claim(TOKEN_ROLE_NAME, memberRole.name()); + } + + return jwtBuilder.compact(); + } + + private Key getKey(JwtConstant jwtConstant) { + return Keys.hmacShaKeyFor( + jwtProperty.getToken().get(jwtConstant).secret().getBytes()); + } + + public AccessTokenDto parseAccessToken(String accessTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(JwtConstant.ACCESS_TOKEN, accessTokenValue); + + return new AccessTokenDto( + Long.parseLong(claims.getBody().getSubject()), + MemberRole.valueOf(claims.getBody().get(TOKEN_ROLE_NAME, String.class)), + accessTokenValue); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto parseRefreshToken(String refreshTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(JwtConstant.REFRESH_TOKEN, refreshTokenValue); + + return new RefreshTokenDto( + Long.parseLong(claims.getBody().getSubject()), + refreshTokenValue, + jwtProperty.getToken().get(JwtConstant.REFRESH_TOKEN).expirationTime()); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + private Jws getClaims(JwtConstant tokenType, String tokenValue) { + Key key = getKey(tokenType); + return Jwts.parserBuilder() + .requireIssuer(jwtProperty.getIssuer()) + .setSigningKey(key) + .build() + .parseClaimsJws(tokenValue); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 38e610ed7..c98fc91aa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,25 @@ spring: datasource: url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID:default} + client-secret: ${GITHUB_CLIENT_SECRET:default} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +jwt: + token: + ACCESS_TOKEN: + secret: ${JWT_ACCESS_TOKEN_SECRET:} + expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME:7200} + REFRESH_TOKEN: + secret: ${JWT_REFRESH_TOKEN_SECRET:} + expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800} + issuer: ${JWT_ISSUER:} diff --git a/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java new file mode 100644 index 000000000..8e827c08c --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.config; + +import com.gdschongik.gdsc.global.config.RedisConfig; +import com.gdschongik.gdsc.global.property.RedisProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; + +@TestConfiguration +@EnableConfigurationProperties({RedisProperty.class}) +@Import({RedisConfig.class}) +public class TestRedisConfig {}