diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4def9d1..2bb2e6b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,9 +8,21 @@ on: env: AWS_REGION: ap-northeast-2 - AWS_S3_BUCKET: mapddang-s3 + AWS_S3_BUCKET: ${{ secrets.AWS_BUCKET_NAME }} AWS_CODE_DEPLOY_APPLICATION: mapddang-CD AWS_CODE_DEPLOY_GROUP: mapddang-CD-group + MYSQL_URL: ${{ secrets.MYSQL_URL }} + MYSQL_USERNAME: ${{ secrets.MYSQL_USERNAME }} + MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} + APPLE_CLIENT_ID: ${{ secrets.APPLE_CLIENT_ID }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_PRIVATE_KEY: ${{ secrets.APPLE_PRIVATE_KEY }} + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + ACCESS_TOKEN_EXPIRE: ${{ secrets.ACCESS_TOKEN_EXPIRE }} + REFRESH_TOKEN_EXPIRE: ${{ secrets.REFRESH_TOKEN_EXPIRE }} + AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }} + AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }} jobs: build-with-gradle: @@ -30,19 +42,29 @@ jobs: #gradlew 파일에 실행 권한 부여 - name: Grant execute permission to gradlew run: chmod +x ./gradlew - #프로젝트 빌드(test 제외) + #프로젝트 빌드 - name: Build Project run: ./gradlew clean build -x test #AWS 자격증명 설정 - name: Setup AWS credential uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.EC2_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.EC2_SECRET_KEY }} + aws-access-key-id: ${{ secrets.AWS_S3_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_S3_SECRET_KEY }} aws-region: ap-northeast-2 #ZIP 파일을 S3 버킷에 업로드 - name: Upload to AWS S3 - run: aws deploy push --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} --ignore-hidden-files --s3-location s3://$AWS_S3_BUCKET/cicdtest/$GITHUB_SHA.zip --source . + run: | + aws deploy push \ + --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} \ + --ignore-hidden-files \ + --s3-location s3://$AWS_S3_BUCKET/cicdtest/$GITHUB_SHA.zip \ + --source . #업로드된 ZIP 파일을 CodeDeploy로 배포 - name: AWS Code Deploy - run: aws deploy create-deployment --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} --deployment-config-name CodeDeployDefault.AllAtOnce --deployment-group-name ${{ env.AWS_CODE_DEPLOY_GROUP }} --s3-location bucket=$AWS_S3_BUCKET,key=cicdtest/$GITHUB_SHA.zip,bundleType=zip + run: | + aws deploy create-deployment \ + --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} \ + --deployment-config-name CodeDeployDefault.AllAtOnce \ + --deployment-group-name ${{ env.AWS_CODE_DEPLOY_GROUP }} \ + --s3-location bucket=$AWS_S3_BUCKET,bundleType=zip,key=cicdtest/$GITHUB_SHA.zip \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4113fd0..d3a055c 100644 --- a/build.gradle +++ b/build.gradle @@ -24,16 +24,22 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + //OpenFeign implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.3' + //JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.bouncycastle:bcprov-jdk18on:1.75' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.75' + + //AWS S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/AppleClient.java b/src/main/java/com/dnd/dndtravel/auth/apple/AppleClient.java deleted file mode 100644 index 401c1e8..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/AppleClient.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.dnd.dndtravel.auth.apple; - -import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; - -@FeignClient(name = "apple-public-key", url = "https://appleid.apple.com") -public interface AppleClient { - @GetMapping("/auth/keys") - ApplePublicKeys getApplePublicKeys(); -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/AppleOauthService.java b/src/main/java/com/dnd/dndtravel/auth/apple/AppleOauthService.java deleted file mode 100644 index 2206972..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/AppleOauthService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.dnd.dndtravel.auth.apple; - -import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys; -import com.dnd.dndtravel.auth.apple.dto.AppleUser; -import io.jsonwebtoken.Claims; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.security.PublicKey; -import java.util.Map; - -@RequiredArgsConstructor -@Component -public class AppleOauthService { - private static final String DEFAULT_NAME = "apple"; - private static final String CLAIM_EMAIL = "email"; - private final AppleTokenParser appleTokenParser; - private final AppleClient appleClient; - private final ApplePublicKeyGenerator applePublicKeyGenerator; - - public AppleUser createAppleUser(final String appleToken) { - final Map appleTokenHeader = appleTokenParser.parseHeader(appleToken); - final ApplePublicKeys applePublicKeys = appleClient.getApplePublicKeys(); - final PublicKey publicKey = applePublicKeyGenerator.generate(appleTokenHeader, applePublicKeys); - final Claims claims = appleTokenParser.extractClaims(appleToken, publicKey); - return new AppleUser(DEFAULT_NAME, claims.get(CLAIM_EMAIL, String.class)); - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/ApplePublicKeyGenerator.java b/src/main/java/com/dnd/dndtravel/auth/apple/ApplePublicKeyGenerator.java deleted file mode 100644 index b08519b..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/ApplePublicKeyGenerator.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.dnd.dndtravel.auth.apple; - -import com.dnd.dndtravel.auth.apple.dto.ApplePublicKey; -import com.dnd.dndtravel.auth.apple.dto.ApplePublicKeys; -import org.springframework.stereotype.Component; - -import java.math.BigInteger; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.RSAPublicKeySpec; -import java.util.Base64; -import java.util.Map; - -@Component -public class ApplePublicKeyGenerator { - private static final String SIGN_ALGORITHM_HEADER = "alg"; - private static final String KEY_ID_HEADER = "kid"; - private static final int POSITIVE_SIGN_NUMBER = 1; - - public PublicKey generate(final Map headers, final ApplePublicKeys publicKeys) { - final ApplePublicKey applePublicKey = publicKeys.getMatchingKey( - headers.get(SIGN_ALGORITHM_HEADER), - headers.get(KEY_ID_HEADER) - ); - return generatePublicKey(applePublicKey); - } - - private PublicKey generatePublicKey(final ApplePublicKey applePublicKey) { - final byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.getN()); - final byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.getE()); - - final BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes); - final BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes); - final RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(n, e); - - try { - final KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.getKty()); - return keyFactory.generatePublic(rsaPublicKeySpec); - } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) { - throw new RuntimeException("잘못된 애플 키"); - } - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/AppleTokenParser.java b/src/main/java/com/dnd/dndtravel/auth/apple/AppleTokenParser.java deleted file mode 100644 index 8b071f0..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/AppleTokenParser.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.dnd.dndtravel.auth.apple; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.UnsupportedJwtException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.security.PublicKey; -import java.util.Map; - -/* -JWK 리스트 조회 -헤더 부분을 디코딩하여 일치하는 jwk를 찾을 alg, kid 값을 얻고, id_token Claim 추출 - */ -@RequiredArgsConstructor -@Component -public class AppleTokenParser { - private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\."; - private static final int HEADER_INDEX = 0; - - private final ObjectMapper objectMapper; - - public Map parseHeader(final String appleToken) { - try { - final String decodedHeader = appleToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX]; - return objectMapper.readValue(decodedHeader, Map.class); - } catch (JsonMappingException e) { - throw new RuntimeException("appleToken 값이 jwt 형식인지, 값이 정상적인지 확인해주세요."); - } catch (JsonProcessingException e) { - throw new RuntimeException("디코드된 헤더를 Map 형태로 분류할 수 없습니다. 헤더를 확인해주세요."); - } - } - - public Claims extractClaims(final String appleToken, final PublicKey publicKey) { - try { - return Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(appleToken) - .getPayload(); - } catch (UnsupportedJwtException e) { - throw new UnsupportedJwtException("지원되지 않는 jwt 타입"); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("비어있는 jwt"); - } catch (JwtException e) { - throw new JwtException("jwt 검증 or 분석 오류"); - } - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKey.java b/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKey.java deleted file mode 100644 index a59abdf..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKey.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.dnd.dndtravel.auth.apple.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; - -/* -id_token 검증 -애플 서버에서 jwk 리스트를 받아와 이를 일급 컬렉션 형태로 관리 - */ -@Getter -public class ApplePublicKey { - private final String kty; //Key Type(RSA or EC(Elliptic Curve)) - private final String kid; //key ID - private final String use; //퍼블릭 키가 어떤 용도로 사용되는지 명시 ("sig"(signature) or "enc"(encryption)) - private final String alg; //어떤 알고리즘을 사용하는지 - private final String n; //RSA modulus - private final String e; //RSA public exponent - - public boolean isSameAlg(final String alg) { - return this.alg.equals(alg); - } - - public boolean isSameKid(final String kid) { - return this.kid.equals(kid); - } - - @JsonCreator - public ApplePublicKey(@JsonProperty("kty") final String kty, - @JsonProperty("kid") final String kid, - @JsonProperty("use") final String use, - @JsonProperty("alg") final String alg, - @JsonProperty("n") final String n, - @JsonProperty("e") final String e) { - this.kty = kty; - this.kid = kid; - this.use = use; - this.alg = alg; - this.n = n; - this.e = e; - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKeys.java b/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKeys.java deleted file mode 100644 index 5ef6db7..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/dto/ApplePublicKeys.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.dnd.dndtravel.auth.apple.dto; - -import java.util.List; - -public class ApplePublicKeys { - private List keys; - - public ApplePublicKey getMatchingKey(final String alg, final String kid) { - return keys.stream() - .filter(key -> key.isSameAlg(alg) && key.isSameKid(kid)) - .findFirst() - .orElseThrow(() -> new RuntimeException("잘못된 토큰 형태입니다.")); - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/apple/dto/AppleUser.java b/src/main/java/com/dnd/dndtravel/auth/apple/dto/AppleUser.java deleted file mode 100644 index b2f7ba7..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/apple/dto/AppleUser.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.dnd.dndtravel.auth.apple.dto; - -import lombok.Getter; - -@Getter -public class AppleUser { - private final String name; - private final String email; - - public AppleUser(final String name, final String email) { - this.name = name; - this.email = email; - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/config/AppleFeignClientConfiguration.java b/src/main/java/com/dnd/dndtravel/auth/config/AppleFeignClientConfiguration.java new file mode 100644 index 0000000..fb46af1 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/config/AppleFeignClientConfiguration.java @@ -0,0 +1,14 @@ +package com.dnd.dndtravel.auth.config; + +import org.springframework.context.annotation.Bean; + +import com.dnd.dndtravel.auth.service.AppleFeignClientErrorDecoder; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class AppleFeignClientConfiguration { + + @Bean + public AppleFeignClientErrorDecoder appleFeignClientErrorDecoder() { + return new AppleFeignClientErrorDecoder(new ObjectMapper()); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/config/AppleProperties.java b/src/main/java/com/dnd/dndtravel/auth/config/AppleProperties.java new file mode 100644 index 0000000..81d17f5 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/config/AppleProperties.java @@ -0,0 +1,20 @@ +package com.dnd.dndtravel.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@ConfigurationProperties(prefix = "social-login.provider.apple") +@Getter +@Setter // 프로퍼티 주입에 필수 +public class AppleProperties { + private String grantType; + private String clientId; + private String keyId; + private String teamId; + private String audience; + private String privateKey; +} diff --git a/src/main/java/com/dnd/dndtravel/auth/config/JwtFilter.java b/src/main/java/com/dnd/dndtravel/auth/config/JwtFilter.java deleted file mode 100644 index 6577da2..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/config/JwtFilter.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.dnd.dndtravel.auth.config; - -import io.jsonwebtoken.io.IOException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.GenericFilterBean; - -@RequiredArgsConstructor -public class JwtFilter extends GenericFilterBean { - - private static final String ACCESS_HEADER= "Authorization"; - private static final int BEARER_SPLIT = 7; - private final JwtProvider jwtProvider; - - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException, java.io.IOException { - HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; - String token = resolveToken(httpServletRequest); - String requestURI = httpServletRequest.getRequestURI(); - - if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) { - Authentication authentication = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - else { - //예외 처리 - System.out.println("유효한 jwt 토큰이 없습니다. uri: " + requestURI); - } - - filterChain.doFilter(servletRequest, servletResponse); - } - - // Request Header 토큰 정보 추출 - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(ACCESS_HEADER); - - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(BEARER_SPLIT); - } - - return null; - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/config/JwtProvider.java b/src/main/java/com/dnd/dndtravel/auth/config/JwtProvider.java deleted file mode 100644 index b7a790e..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/config/JwtProvider.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.dnd.dndtravel.auth.config; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SecurityException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.beans.factory.InitializingBean; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; - -import java.security.Key; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.stream.Collectors; - -@Component -public class JwtProvider implements InitializingBean { - - private static final String AUTHORITIES_KEY = "memberId"; - private final long tokenValidityInMilliseconds; - private final String secretKey; - private Key key; - - public JwtProvider( - @Value("${JWT_SECRET_KEY}") String secretKey, - @Value("86400") long tokenValidityInSeconds) { - this.secretKey = secretKey; - this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000; - } - - // secretKey 값을 Base64 Decode 해서 key 변수에 할당 - @Override - public void afterPropertiesSet() { - byte[] keyBytes = secretKey.getBytes(); //32바이트 이상 - this.key = Keys.hmacShaKeyFor(keyBytes); - } - - // JWT Access 토큰 생성 - public String createToken(Authentication authentication) { - Long memberId = Long.parseLong(authentication.getName()); - long now = (new Date()).getTime(); - Date validity = new Date(now + this.tokenValidityInMilliseconds); //1일 - - return Jwts.builder() - .setSubject(String.valueOf(memberId)) - .claim(AUTHORITIES_KEY, memberId) - .signWith(key, SignatureAlgorithm.HS256) - .setExpiration(validity) - .compact(); - } - - // Refresh 토큰 생성 - public String createRefreshToken(Long memberId) { - long now = (new Date()).getTime(); - Date validity = new Date(now + this.tokenValidityInMilliseconds * 14); // 14일 - - return Jwts.builder() - .setSubject(String.valueOf(memberId)) - .signWith(key, SignatureAlgorithm.HS256) - .setExpiration(validity) - .compact(); - } - - // authentication 객체 생성 - public Authentication getAuthentication(String token) { - Claims claims = Jwts.parser() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - Collection authorities = - Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - return new UsernamePasswordAuthenticationToken(claims.get(AUTHORITIES_KEY), token, authorities); - } - - // Token 유효성 검증 - public boolean validateToken(String token) { - try { - Jwts.parser().setSigningKey(key).build().parseClaimsJws(token); - return true; - } catch (SecurityException | MalformedJwtException e) { - // 커스텀 예외 처리 - System.out.println("잘못된 JWT Signature"); - } catch (ExpiredJwtException e) { - System.out.println("만료된 JWT 토큰"); - } catch (UnsupportedJwtException e) { - System.out.println("지원 되지 않는 JWT 토큰"); - } catch (IllegalArgumentException e) { - System.out.println("잘못된 JWT 토큰"); - } - - return false; - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/config/JwtSecurityConfig.java b/src/main/java/com/dnd/dndtravel/auth/config/JwtSecurityConfig.java deleted file mode 100644 index 9f0ff62..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/config/JwtSecurityConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.dnd.dndtravel.auth.config; - -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.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class JwtSecurityConfig { - - private final JwtProvider jwtProvider; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf() - .disable() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .authorizeHttpRequests() - .requestMatchers("/login/**").permitAll() - .anyRequest().authenticated() - .and() - .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java b/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java new file mode 100644 index 0000000..c805a30 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java @@ -0,0 +1,44 @@ +package com.dnd.dndtravel.auth.controller; + +import com.dnd.dndtravel.auth.service.dto.response.AppleIdTokenPayload; +import com.dnd.dndtravel.auth.service.AppleOAuthService; +import com.dnd.dndtravel.auth.service.JwtTokenService; +import com.dnd.dndtravel.auth.controller.request.AppleLoginRequest; +import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; +import com.dnd.dndtravel.member.domain.Member; +import com.dnd.dndtravel.member.service.MemberService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class AuthController { + private final AppleOAuthService appleOAuthService; + private final JwtTokenService jwtTokenService; + private final MemberService memberService; + + //todo 클라이언트에서 실제 인증코드 보내주면 테스트 진행 필요 + @PostMapping("/login/oauth2/apple") + public ResponseEntity appleOAuthLogin(@RequestBody AppleLoginRequest appleLoginRequest) { + // 클라이언트에서 준 code 값으로 apple의 IdToken Payload를 얻어온다 + AppleIdTokenPayload tokenPayload = appleOAuthService.get(appleLoginRequest.appleToken()); + + // apple에서 가져온 유저정보를 DB에 저장 + Member member = memberService.saveMember(tokenPayload.name(), tokenPayload.email(), appleLoginRequest.selectedColor()); + + // 클라이언트와 주고받을 user token(access , refresh) 생성 + TokenResponse tokenResponse = jwtTokenService.generateTokens(member.getId()); + + // refresh token 재발급 필요시 + if (tokenResponse == null) { + return ResponseEntity.noContent().build(); + } + + return ResponseEntity.ok(tokenResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/AuthTokenController.java b/src/main/java/com/dnd/dndtravel/auth/controller/AuthTokenController.java deleted file mode 100644 index c0e14dd..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/controller/AuthTokenController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.dnd.dndtravel.auth.controller; - -import com.dnd.dndtravel.auth.apple.AppleOauthService; -import com.dnd.dndtravel.auth.apple.dto.AppleUser; -import com.dnd.dndtravel.auth.config.JwtProvider; -import com.dnd.dndtravel.auth.repository.AuthTokenRepository; -import com.dnd.dndtravel.auth.service.dto.request.AppleLoginRequest; -import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; -import com.dnd.dndtravel.member.domain.Member; -import com.dnd.dndtravel.member.service.MemberService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Collections; - -@RequiredArgsConstructor -@RestController -public class AuthTokenController { - - private final MemberService memberService; - private final AppleOauthService appleOauthService; - private final JwtProvider jwtProvider; - private final AuthTokenRepository authTokenRepository; - - @PostMapping("/login/oauth2/apple") - public TokenResponse appleOauthLogin(@RequestBody AppleLoginRequest appleLoginRequest) { - AppleUser appleUser = appleOauthService.createAppleUser(appleLoginRequest.appleToken()); - Member member = memberService.saveMember(appleUser); - - Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), null, Collections.emptyList()); - String accessToken = jwtProvider.createToken(authentication); - String refreshToken = jwtProvider.createRefreshToken(member.getId()); - authTokenRepository.saveRefreshToken(member.getId(), refreshToken); // refreshToken은 DB에 저장 - - return new TokenResponse(accessToken); - } -} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java b/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java new file mode 100644 index 0000000..4f76346 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.auth.controller.request; + +//todo 필드값들 유효성 체크 +// todo 입력 색상 예시는 클라에서 String 타입들. RED, ORANGE, YELLOW, MELON, BLUE, PURPLE +public record AppleLoginRequest( + String appleToken, + String selectedColor +){ +} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/domain/RefreshTokenCollector.java b/src/main/java/com/dnd/dndtravel/auth/domain/RefreshToken.java similarity index 53% rename from src/main/java/com/dnd/dndtravel/auth/domain/RefreshTokenCollector.java rename to src/main/java/com/dnd/dndtravel/auth/domain/RefreshToken.java index 48423cf..35400c7 100644 --- a/src/main/java/com/dnd/dndtravel/auth/domain/RefreshTokenCollector.java +++ b/src/main/java/com/dnd/dndtravel/auth/domain/RefreshToken.java @@ -1,5 +1,7 @@ package com.dnd.dndtravel.auth.domain; +import java.time.LocalDateTime; + import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -8,7 +10,8 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RefreshTokenCollector { +public class RefreshToken { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -19,8 +22,19 @@ public class RefreshTokenCollector { @Column(nullable = false) private String refreshToken; - public RefreshTokenCollector(Long memberId, String refreshToken) { + private LocalDateTime expiredTime; + + private RefreshToken(Long memberId, String refreshToken) { this.memberId = memberId; this.refreshToken = refreshToken; + this.expiredTime = LocalDateTime.now(); + } + + public static RefreshToken of(Long memberId, String refreshToken) { + return new RefreshToken(memberId, refreshToken); + } + + public boolean isExpire() { + return this.expiredTime.isBefore(LocalDateTime.now()); } } \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/repository/AuthTokenRepository.java b/src/main/java/com/dnd/dndtravel/auth/repository/AuthTokenRepository.java deleted file mode 100644 index ca7e3c0..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/repository/AuthTokenRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dnd.dndtravel.auth.repository; - -import com.dnd.dndtravel.auth.domain.RefreshTokenCollector; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -@Repository -public interface AuthTokenRepository extends JpaRepository { - @Modifying - @Query("UPDATE RefreshTokenCollector a SET a.refreshToken = :refreshToken WHERE a.id = :id") - void saveRefreshToken(@Param("id") Long id, @Param("refreshToken") String refreshToken); -} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/repository/RefreshTokenRepository.java b/src/main/java/com/dnd/dndtravel/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..05d9236 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.dnd.dndtravel.auth.repository; + +import com.dnd.dndtravel.auth.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + RefreshToken findByMemberId(Long memberId); +} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/service/AppleClient.java b/src/main/java/com/dnd/dndtravel/auth/service/AppleClient.java new file mode 100644 index 0000000..fc31c33 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/AppleClient.java @@ -0,0 +1,30 @@ +package com.dnd.dndtravel.auth.service; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.dnd.dndtravel.auth.service.dto.response.AppleSocialTokenInfoResponse; +import com.dnd.dndtravel.auth.config.AppleFeignClientConfiguration; + +@FeignClient( + name = "apple-auth", + url = "https://appleid.apple.com", + configuration = AppleFeignClientConfiguration.class +) +public interface AppleClient { + /** + * @param clientId(required) : 맵땅의 식별자값 + * @param clientSecret(required) : 개발자가 만든 비밀 JWT 토큰, 개발자 계정과 비밀키로 애플에 로그인할때 사용된다. + * @param grantType(required) : refresh token과 authorization code 를 검증하기위해 사용됨, 우린 현재 authorization code 사용중 + * @param code : 애플에게 받은 오직 5분만 유효한 일회용 인증코드, authorization code 검증 용도로 필요하다. + * @return + */ + @PostMapping("/auth/token") + AppleSocialTokenInfoResponse getIdToken( + @RequestParam("client_id") String clientId, + @RequestParam("client_secret") String clientSecret, + @RequestParam("grant_type") String grantType, + @RequestParam("code") String code + ); +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/AppleFeignClientErrorDecoder.java b/src/main/java/com/dnd/dndtravel/auth/service/AppleFeignClientErrorDecoder.java new file mode 100644 index 0000000..4972d82 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/AppleFeignClientErrorDecoder.java @@ -0,0 +1,39 @@ +package com.dnd.dndtravel.auth.service; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import feign.Response; +import feign.codec.ErrorDecoder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class AppleFeignClientErrorDecoder implements ErrorDecoder { + + private final ObjectMapper objectMapper; + + /** + * 애플 소셜 로그인 Feign API 연동 시 발생되는 오류에 대해서 예외 처리를 수행. + * + * @param methodKey Feign Client 메서드 이름 + * @param response 응답 정보 + */ + @Override + public Exception decode(String methodKey, Response response) { + Object body = null; + if (response != null && response.body() != null) { + try { + body = objectMapper.readValue(response.body().asInputStream(), Object.class); + } catch (IOException e) { + log.error("Error decoding response body", e); + } + } + + log.error("애플 소셜 로그인 Feign API Feign Client 호출 중 오류가 발생되었습니다. body: {}", body); + + return new RuntimeException("애플 소셜 로그인 Feign API Feign Client 호출 오류"); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java b/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java new file mode 100644 index 0000000..db94c78 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java @@ -0,0 +1,90 @@ +package com.dnd.dndtravel.auth.service; + +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.springframework.stereotype.Component; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +import java.security.PrivateKey; +import java.security.Security; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Date; + +import com.dnd.dndtravel.auth.service.dto.response.AppleIdTokenPayload; +import com.dnd.dndtravel.auth.config.AppleProperties; + +/** + * private key와 기타 설정값들로 client secret을 생성한다 + * client_secret: 개발자가 생성한 Secret JWT 토큰, 개발자 계정과 연결된 Apple로 로그인 개인 키를 사용, authorization code와 refresh token 검증 요청에 해당 파라미터 필요 + * client_secret을 만드는 관련 docs + * - https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + * + * clinet_secret jwt 토큰 디코딩 예시 형식 + * { + * //JWT 헤더 + * "alg": "ES256", // 토큰에 서명된 알고리즘 + * "kid": "ABC123DEFG" // 개발자 계정과 연결된 계정 + private key 로 만들어진 식별자 + * } + * { + * "iss": "DEF123GHIJ",// 팀ID + * "iat": 1437179036, // + * "exp": 1493298100, // 만료시간 + * "aud": "https://appleid.apple.com", + * "sub": "com.mytest.app" + * } + * + */ + +@RequiredArgsConstructor +@Component +@Slf4j +public class AppleOAuthService { + private final AppleClient appleClient; + private final AppleProperties appleProperties; + + public AppleIdTokenPayload get(String authorizationCode) { + String idToken = appleClient.getIdToken( + appleProperties.getClientId(), + generateClientSecret(), + appleProperties.getGrantType(), + authorizationCode + ).idToken(); + + return TokenDecoder.decodePayload(idToken, AppleIdTokenPayload.class); + } + + private String generateClientSecret() { + LocalDateTime expiration = LocalDateTime.now().plusMinutes(5); + + return Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKeyId()) + .setIssuer(appleProperties.getTeamId()) + .setAudience(appleProperties.getAudience()) + .setSubject(appleProperties.getClientId()) + .setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant())) + .setIssuedAt(new Date()) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } + + private PrivateKey getPrivateKey() { + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + + try { + byte[] privateKeyBytes = Base64.getDecoder().decode(appleProperties.getPrivateKey()); + + PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes); + return converter.getPrivateKey(privateKeyInfo); + } catch (Exception e) { + throw new RuntimeException("Error converting private key from String", e); + } + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java new file mode 100644 index 0000000..99671c6 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java @@ -0,0 +1,45 @@ +package com.dnd.dndtravel.auth.service; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtProvider { + private static final String CLAIM_CONTENT = "memberId"; + private final long accessTokenExpiredTime; + private final long refreshTokenExpiredTime; + private final String secretKey; + + public JwtProvider( + @Value("${jwt.secret-key}") String secretKey, + @Value("${jwt.access-token-expired-ms}") long accessTokenExpiredTime, + @Value("${jwt.refresh-token-expired-ms}") long refreshTokenExpiredTime + ) { + this.secretKey = secretKey; + this.accessTokenExpiredTime = accessTokenExpiredTime; + this.refreshTokenExpiredTime = refreshTokenExpiredTime; + } + + public String accessToken(Long memberId) { + return Jwts.builder() + .claim(CLAIM_CONTENT, memberId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + this.accessTokenExpiredTime)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(this.secretKey))) + .compact(); + } + + public String refreshToken(Long memberId) { + return Jwts.builder() + .claim(CLAIM_CONTENT, memberId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + this.refreshTokenExpiredTime)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(this.secretKey))) + .compact(); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java new file mode 100644 index 0000000..a52ddea --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java @@ -0,0 +1,33 @@ +package com.dnd.dndtravel.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.dndtravel.auth.domain.RefreshToken; +import com.dnd.dndtravel.auth.repository.RefreshTokenRepository; +import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class JwtTokenService { + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + // todo 현재 refreshtoken하나로 계속해서 발급해주는 모델인데, 이는 리프레쉬 탈취시 보안위협있음. 추후 개선 필요 + @Transactional + public TokenResponse generateTokens(Long memberId) { + RefreshToken refreshToken = refreshTokenRepository.findByMemberId(memberId); + + if (refreshToken == null) { + String newRefreshToken = jwtProvider.refreshToken(memberId); + refreshTokenRepository.save(RefreshToken.of(memberId, newRefreshToken)); // refreshToken은 DB에 저장 + return new TokenResponse(jwtProvider.accessToken(memberId), newRefreshToken); + } else if (refreshToken.isExpire()) { + return null; + } + + return new TokenResponse(jwtProvider.accessToken(memberId), null); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java new file mode 100644 index 0000000..a789b2f --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java @@ -0,0 +1,31 @@ +package com.dnd.dndtravel.auth.service; + +import java.util.Base64; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class TokenDecoder { + /** + * 헤더, 페이로드, 서명 세 부분을 .으로 분리해 페이로드만 가져와서, Base64디코딩 하고 얻어온 바이트배열을 + * String변환, JSON 변환을 거쳐 targetClass의 객체로 변환 + * @param token + * @param targetClass + * @return + * @param + */ + public static T decodePayload(String token, Class targetClass) { + String[] tokenParts = token.split("\\."); + String payloadJWT = tokenParts[1]; + Base64.Decoder decoder = Base64.getUrlDecoder(); + String payload = new String(decoder.decode(payloadJWT)); + ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + try { + return objectMapper.readValue(payload, targetClass); + } catch (Exception e) { + throw new RuntimeException("Error decoding token payload", e); + } + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/dto/AuthMember.java b/src/main/java/com/dnd/dndtravel/auth/service/dto/AuthMember.java deleted file mode 100644 index 1125006..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/service/dto/AuthMember.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.dnd.dndtravel.auth.service.dto; - -import com.dnd.dndtravel.member.domain.Member; -import lombok.Getter; - -@Getter -public class AuthMember { - - private final Long id; - - private final String name; - - private final String email; - - public AuthMember(final Member member) { - this.id = member.getId(); - this.name = member.getName(); - this.email = member.getEmail(); - } -} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/dto/request/AppleLoginRequest.java b/src/main/java/com/dnd/dndtravel/auth/service/dto/request/AppleLoginRequest.java deleted file mode 100644 index 5440b50..0000000 --- a/src/main/java/com/dnd/dndtravel/auth/service/dto/request/AppleLoginRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.dnd.dndtravel.auth.service.dto.request; - -public record AppleLoginRequest( - String appleToken -){ -} \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleIdTokenPayload.java b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleIdTokenPayload.java new file mode 100644 index 0000000..ebb8724 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleIdTokenPayload.java @@ -0,0 +1,14 @@ +package com.dnd.dndtravel.auth.service.dto.response; + +/** + * apple의 idtoken을 까서 나오는 값들 + * @param sub: 사용자 고유 식별자(subject) + * @param name + * @param email + */ +public record AppleIdTokenPayload( + String sub, + String name, + String email +) { +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleSocialTokenInfoResponse.java b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleSocialTokenInfoResponse.java new file mode 100644 index 0000000..9f4cdcd --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/AppleSocialTokenInfoResponse.java @@ -0,0 +1,25 @@ +package com.dnd.dndtravel.auth.service.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * + * @param accessToken + * @param tokenType: 액세스 토큰은 항상 Bearer + * @param expiresIn + * @param refreshToken + * @param idToken: 사용자 신원정보가 포함된 JWT + */ +public record AppleSocialTokenInfoResponse( + @JsonProperty("access_token") + String accessToken, + @JsonProperty("token_type") + String tokenType, + @JsonProperty("expires_in") + Long expiresIn, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("id_token") + String idToken +) { +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/dto/response/TokenResponse.java b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/TokenResponse.java index e41c3b2..e1c096b 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/dto/response/TokenResponse.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/dto/response/TokenResponse.java @@ -1,6 +1,7 @@ package com.dnd.dndtravel.auth.service.dto.response; public record TokenResponse( - String accessToken + String accessToken, + String refreshToken ) { } \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/config/S3Config.java b/src/main/java/com/dnd/dndtravel/config/S3Config.java new file mode 100644 index 0000000..ad6db25 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/config/S3Config.java @@ -0,0 +1,34 @@ +package com.dnd.dndtravel.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/controller/MapController.java b/src/main/java/com/dnd/dndtravel/map/controller/MapController.java index a750a13..fbb4737 100644 --- a/src/main/java/com/dnd/dndtravel/map/controller/MapController.java +++ b/src/main/java/com/dnd/dndtravel/map/controller/MapController.java @@ -20,6 +20,7 @@ public class MapController { @Operation(summary = "전체 지역 조회", description = "전체 지역 방문 횟수를 조회합니다.") @GetMapping("/maps") public RegionResponse map() { - return mapService.allRegions(); + Long memberId = 1L; + return mapService.allRegions(memberId); } } diff --git a/src/main/java/com/dnd/dndtravel/map/domain/MemberRegion.java b/src/main/java/com/dnd/dndtravel/map/domain/MemberRegion.java new file mode 100644 index 0000000..d1af6a2 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/domain/MemberRegion.java @@ -0,0 +1,54 @@ +package com.dnd.dndtravel.map.domain; + +import com.dnd.dndtravel.member.domain.Member; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class MemberRegion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + + private int visitCount; // 방문 횟수 + + @Builder + private MemberRegion(Member member, Region region, int visitCount) { + this.member = member; + this.region = region; + this.visitCount = visitCount; + } + + public static MemberRegion of(Member member, Region region, int visitCount) { + return MemberRegion.builder() + .member(member) + .region(region) + .visitCount(visitCount) + .build(); + } + + public boolean isVisited() { + return this.visitCount > 0; + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/domain/Region.java b/src/main/java/com/dnd/dndtravel/map/domain/Region.java index ef0d29c..5af1273 100644 --- a/src/main/java/com/dnd/dndtravel/map/domain/Region.java +++ b/src/main/java/com/dnd/dndtravel/map/domain/Region.java @@ -21,19 +21,15 @@ public class Region { private String name; // 지역 이름 - @Enumerated(EnumType.STRING) - private VisitOpacity visitOpacity; // 방문 횟수(색의 opacity) - - public static Region of(String name, VisitOpacity visitOpacity) { - return new Region(name, visitOpacity); + public static Region of(String name) { + return new Region(name); } - public boolean isVisited() { - return visitOpacity.isNotZero(); + private Region(String name) { + this.name = name; } - private Region(String name, VisitOpacity visitOpacity) { - this.name = name; - this.visitOpacity = visitOpacity; + public boolean isEqualTo(String name) { + return this.name.equals(name); } } diff --git a/src/main/java/com/dnd/dndtravel/map/repository/MemberRegionRepository.java b/src/main/java/com/dnd/dndtravel/map/repository/MemberRegionRepository.java new file mode 100644 index 0000000..c09db39 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/repository/MemberRegionRepository.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.map.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dnd.dndtravel.map.domain.MemberRegion; + +public interface MemberRegionRepository extends JpaRepository { + List findByMemberId(Long memberId); +} diff --git a/src/main/java/com/dnd/dndtravel/map/repository/MapRepository.java b/src/main/java/com/dnd/dndtravel/map/repository/RegionRepository.java similarity index 51% rename from src/main/java/com/dnd/dndtravel/map/repository/MapRepository.java rename to src/main/java/com/dnd/dndtravel/map/repository/RegionRepository.java index 00e8fd5..4793c85 100644 --- a/src/main/java/com/dnd/dndtravel/map/repository/MapRepository.java +++ b/src/main/java/com/dnd/dndtravel/map/repository/RegionRepository.java @@ -1,8 +1,11 @@ package com.dnd.dndtravel.map.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.dnd.dndtravel.map.domain.Region; -public interface MapRepository extends JpaRepository { +public interface RegionRepository extends JpaRepository { + Optional findByName(String region); } diff --git a/src/main/java/com/dnd/dndtravel/map/service/MapService.java b/src/main/java/com/dnd/dndtravel/map/service/MapService.java index 67a1056..5a43eaf 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/MapService.java +++ b/src/main/java/com/dnd/dndtravel/map/service/MapService.java @@ -1,9 +1,23 @@ package com.dnd.dndtravel.map.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.dndtravel.map.domain.MemberRegion; +import com.dnd.dndtravel.map.repository.MemberRegionRepository; +import com.dnd.dndtravel.map.repository.RegionRepository; + +import com.dnd.dndtravel.member.domain.Member; +import com.dnd.dndtravel.member.repository.MemberRepository; + import com.dnd.dndtravel.map.domain.Region; -import com.dnd.dndtravel.map.repository.MapRepository; import com.dnd.dndtravel.map.service.dto.RegionDto; import com.dnd.dndtravel.map.service.dto.response.RegionResponse; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,20 +27,46 @@ @RequiredArgsConstructor @Service public class MapService { - - private final MapRepository mapRepository; + private final RegionRepository regionRepository; + private final MemberRepository memberRepository; + private final MemberRegionRepository memberRegionRepository; @Transactional(readOnly = true) - public RegionResponse allRegions() { - List all = mapRepository.findAll(); + public RegionResponse allRegions(Long memberId) { + //todo custom ex + Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); - List regions = all.stream() + // 유저의 지역 방문기록 전부 가져온다 + List memberRegions = memberRegionRepository.findByMemberId(memberId); + List regions = regionRepository.findAll().stream() .map(RegionDto::from) .toList(); - int visitCount = (int)all.stream() - .filter(Region::isVisited) - .count(); - return new RegionResponse(regions, visitCount); + if (memberRegions.isEmpty()) { + return new RegionResponse(regions, member.getSelectedColor()); + } + + return new RegionResponse( + updateRegionDto(regions, memberRegions), + (int)memberRegions.stream() + .filter(MemberRegion::isVisited) + .count(), + member.getSelectedColor() + ); + } + private List updateRegionDto(List regions, List memberRegions) { + return regions.stream() + .map(regionDto -> { + String regionName = regionDto.name(); + Optional innerMemberRegion = memberRegions.stream() + .filter(memberRegion -> memberRegion.getRegion().isEqualTo(regionName)) + .findFirst(); + + if (innerMemberRegion.isPresent() && innerMemberRegion.get().isVisited()) { + return new RegionDto(regionDto.name(), innerMemberRegion.get().getVisitCount()); + } + return regionDto; + }) + .toList(); } } diff --git a/src/main/java/com/dnd/dndtravel/map/service/dto/RegionDto.java b/src/main/java/com/dnd/dndtravel/map/service/dto/RegionDto.java index 5434623..733a479 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/dto/RegionDto.java +++ b/src/main/java/com/dnd/dndtravel/map/service/dto/RegionDto.java @@ -8,6 +8,6 @@ public record RegionDto( ) { public static RegionDto from(Region region) { - return new RegionDto(region.getName(), region.getVisitOpacity().toInt()); + return new RegionDto(region.getName(), 0); } } diff --git a/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java b/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java index 2eb49a4..00fce0b 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java +++ b/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java @@ -1,18 +1,25 @@ package com.dnd.dndtravel.map.service.dto.response; import com.dnd.dndtravel.map.service.dto.RegionDto; +import com.dnd.dndtravel.member.domain.SelectedColor; import java.util.List; public record RegionResponse( - List regions, - int visitCount, - int totalCount + List regions, // 지역별 opacity 정보, 땅 이름 + int visitCount, // 방문 지도 개수 + int totalCount, // 전체 땅 개수 + SelectedColor selectedColor // 선택된 컬러 ) { private static final int TOTAL_COUNT = 16; // 전체 지역구의 개수, 변경가능성이 낮아 16이라는 상수로 고정 + private static final int DEFAULT_VISIT_COUNT = 0; - public RegionResponse(List regions, int visitCount) { - this(regions, visitCount, TOTAL_COUNT); + public RegionResponse(List regions, int visitCount, SelectedColor selectedColor) { + this(regions, visitCount, TOTAL_COUNT, selectedColor); + } + + public RegionResponse(List regions, SelectedColor selectedColor) { + this(regions, DEFAULT_VISIT_COUNT, TOTAL_COUNT, selectedColor); } } diff --git a/src/main/java/com/dnd/dndtravel/member/domain/Member.java b/src/main/java/com/dnd/dndtravel/member/domain/Member.java index 8691250..df9780a 100644 --- a/src/main/java/com/dnd/dndtravel/member/domain/Member.java +++ b/src/main/java/com/dnd/dndtravel/member/domain/Member.java @@ -1,6 +1,5 @@ package com.dnd.dndtravel.member.domain; -import com.dnd.dndtravel.auth.apple.dto.AppleUser; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -22,16 +21,21 @@ public class Member { @Column(nullable = false) private String email; + @Enumerated(EnumType.STRING) + private SelectedColor selectedColor; // 유저가 선택한 컬러 + @Builder - private Member(String name, String email){ + private Member(String name, String email, SelectedColor selectedColor) { this.name = name; this.email = email; + this.selectedColor = selectedColor; } - public static Member of(AppleUser appleUser) { + public static Member of(String userName, String email, String selectedColor) { return Member.builder() - .name(appleUser.getName()) - .email(appleUser.getEmail()) - .build(); + .name(userName) + .email(email) + .selectedColor(SelectedColor.convertToEnum(selectedColor)) + .build(); } } diff --git a/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java b/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java new file mode 100644 index 0000000..61dc5a1 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java @@ -0,0 +1,27 @@ +package com.dnd.dndtravel.member.domain; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum SelectedColor { + RED("RED"), + ORANGE("ORANGE"), + YELLOW("YELLOW"), + MELON("MELON"), + BLUE("BLUE"), + PURPLE("PURPLE"); + + private final String value; + + SelectedColor(String value) { + this.value = value; + } + + public static SelectedColor convertToEnum(String selectedColor) { + return Arrays.stream(SelectedColor.values()) + .filter(color -> color.getValue().equalsIgnoreCase(selectedColor)) + .findAny().orElseThrow(() -> new RuntimeException("존재하지 않는 색상입니다")); + } +} diff --git a/src/main/java/com/dnd/dndtravel/member/service/MemberService.java b/src/main/java/com/dnd/dndtravel/member/service/MemberService.java index bd12692..973a345 100644 --- a/src/main/java/com/dnd/dndtravel/member/service/MemberService.java +++ b/src/main/java/com/dnd/dndtravel/member/service/MemberService.java @@ -1,6 +1,5 @@ package com.dnd.dndtravel.member.service; -import com.dnd.dndtravel.auth.apple.dto.AppleUser; import com.dnd.dndtravel.member.domain.Member; import com.dnd.dndtravel.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -14,8 +13,8 @@ public class MemberService { private final MemberRepository memberRepository; @Transactional - public Member saveMember(AppleUser appleUser) { - return memberRepository.findByEmail(appleUser.getEmail()) - .orElseGet(() -> memberRepository.save(Member.of(appleUser))); + public Member saveMember(String name, String email, String selectedColor) { + return memberRepository.findByEmail(email) + .orElseGet(() -> memberRepository.save(Member.of(name, email,selectedColor))); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0ca5cc4..96bdde0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,19 +1,37 @@ spring: application: name: dnd-travel-project - profiles: - include: - - db datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} - mvc: - pathmatch: - matching-strategy: ant_path_matcher jpa: - properties: - hibernate: - dialect: org.hibernate.dialect.MySQLDialect - jwt: - secret-key: ${JWT_SECRET_KEY} \ No newline at end of file + open-in-view: false + +social-login: + provider: + apple: + grant-type: authorization_code + client-id: ${APPLE_CLIENT_ID} + key-id: ${APPLE_KEY_ID} # 애플에서 제공하는 키의 ID + team-id: ${APPLE_TEAM_ID} # 애플 개발자 계정의 팀 ID + audience: https://appleid.apple.com + private-key: ${APPLE_PRIVATE_KEY} + +jwt: + secret-key: ${JWT_SECRET_KEY} + access-token-expired-ms: ${ACCESS_TOKEN_EXPIRE} + refresh-token-expired-ms: ${REFRESH_TOKEN_EXPIRE} + +cloud: + aws: + credentials: + accessKey: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + region: + static: ap-northeast-2 + stack: + auto: false + s3: + bucketName: ${AWS_BUCKET_NAME} + diff --git a/src/test/java/com/dnd/dndtravel/map/service/MapServiceTest.java b/src/test/java/com/dnd/dndtravel/map/service/MapServiceTest.java index 9583492..b02fc6d 100644 --- a/src/test/java/com/dnd/dndtravel/map/service/MapServiceTest.java +++ b/src/test/java/com/dnd/dndtravel/map/service/MapServiceTest.java @@ -2,7 +2,6 @@ import com.dnd.dndtravel.map.domain.Region; import com.dnd.dndtravel.map.domain.VisitOpacity; -import com.dnd.dndtravel.map.repository.MapRepository; import com.dnd.dndtravel.map.service.dto.response.RegionResponse; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -11,14 +10,15 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; -import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; - import java.util.List; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import com.dnd.dndtravel.map.domain.MemberRegion; +import com.dnd.dndtravel.map.repository.MemberRegionRepository; +import com.dnd.dndtravel.map.repository.RegionRepository; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @ExtendWith(MockitoExtension.class) @@ -28,16 +28,22 @@ class MapServiceTest { private MapService sut; @Mock - private MapRepository mapRepository; + private MemberRepository memberRepository; + + @Mock + private RegionRepository regionRepository; + + @Mock + private MemberRegionRepository memberRegionRepository; @ParameterizedTest @MethodSource("provideRegionsForTesting") void 전체_지역정보를_조회한다(List regions, int expectedVisitCount, int expectedRegionCount) { // given - given(mapRepository.findAll()).willReturn(regions); + // given(mapRepository.findAll()).willReturn(regions); // when - RegionResponse actual = sut.allRegions(); + RegionResponse actual = sut.allRegions(1L); // then assertThat(actual.regions().size()).isEqualTo(expectedRegionCount); @@ -51,23 +57,23 @@ private static Stream provideRegionsForTesting() { // 모든 지역이 방문되지 않은 경우 Arguments.of(List.of( - Region.of("서울특별시", VisitOpacity.ZERO), - Region.of("부산", VisitOpacity.ZERO), - Region.of("충청도", VisitOpacity.ZERO) + Region.of("서울특별시"), + Region.of("부산"), + Region.of("충청도") ), 0, 3), // 모든 지역이 방문된 경우 Arguments.of(List.of( - Region.of("서울특별시", VisitOpacity.ONE), - Region.of("부산", VisitOpacity.ONE), - Region.of("충청도", VisitOpacity.ONE) + Region.of("서울특별시"), + Region.of("부산"), + Region.of("충청도") ), 3, 3), // 일부 지역만 방문된 경우 Arguments.of(List.of( - Region.of("서울특별시", VisitOpacity.ONE), - Region.of("부산", VisitOpacity.ZERO), - Region.of("충청도", VisitOpacity.ONE) + Region.of("서울특별시"), + Region.of("부산"), + Region.of("충청도") ), 2, 3) ); }