diff --git a/backend/build.gradle b/backend/build.gradle index c631365e..83b5f7f5 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -28,6 +28,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/backend/src/main/java/woowacourse/touroot/authentication/controller/LoginController.java b/backend/src/main/java/woowacourse/touroot/authentication/controller/LoginController.java new file mode 100644 index 00000000..40fc3fae --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/controller/LoginController.java @@ -0,0 +1,24 @@ +package woowacourse.touroot.authentication.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import woowacourse.touroot.authentication.dto.LoginResponse; +import woowacourse.touroot.authentication.service.LoginService; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/login") +public class LoginController { + + private final LoginService loginService; + + @GetMapping("/oauth/kakao") + public ResponseEntity login(@RequestParam(name = "code") String authorizationCode) { + return ResponseEntity.ok() + .body(loginService.login(authorizationCode)); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/dto/KakaoAccessTokenResponse.java b/backend/src/main/java/woowacourse/touroot/authentication/dto/KakaoAccessTokenResponse.java new file mode 100644 index 00000000..47f5ec11 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/dto/KakaoAccessTokenResponse.java @@ -0,0 +1,19 @@ +package woowacourse.touroot.authentication.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoAccessTokenResponse( + @JsonProperty("token_type") + String tokenType, + @JsonProperty("access_token") + String accessToken, + @JsonProperty("expires_in") + Integer expiresIn, + @JsonProperty("refresh_token") + String refreshToken, + @JsonProperty("refresh_token_expires_in") + Integer refreshTokenExpiresIn, + @JsonProperty("scope") + String scope +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/dto/LoginResponse.java b/backend/src/main/java/woowacourse/touroot/authentication/dto/LoginResponse.java new file mode 100644 index 00000000..156f99fc --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/dto/LoginResponse.java @@ -0,0 +1,4 @@ +package woowacourse.touroot.authentication.dto; + +public record LoginResponse(String accessToken) { +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/dto/OauthUserInformationResponse.java b/backend/src/main/java/woowacourse/touroot/authentication/dto/OauthUserInformationResponse.java new file mode 100644 index 00000000..3a453147 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/dto/OauthUserInformationResponse.java @@ -0,0 +1,30 @@ +package woowacourse.touroot.authentication.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OauthUserInformationResponse( + @JsonProperty("id") + Long socialLoginId, + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + + public String nickname() { + return kakaoAccount.kakaoProfile.nickname; + } + + public String profileImage() { + return kakaoAccount.kakaoProfile.image; + } + + private record KakaoAccount( + @JsonProperty("profile") KakaoProfile kakaoProfile + ) { + } + + private record KakaoProfile( + @JsonProperty("nickname") String nickname, + @JsonProperty("profile_image_url") String image + ) { + } +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/JwtTokenProvider.java b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/JwtTokenProvider.java new file mode 100644 index 00000000..1b8f3ee9 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/JwtTokenProvider.java @@ -0,0 +1,37 @@ +package woowacourse.touroot.authentication.infrastructure; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import woowacourse.touroot.member.domain.Member; + +@Component +public class JwtTokenProvider { + + private static final String MEMBER_ID_KEY = "id"; + + private final String secretKey; + private final long validityInMilliseconds; + + public JwtTokenProvider( + @Value("${security.jwt.token.secret-key}") String secretKey, + @Value("${security.jwt.token.expire-length}") long validityInMilliseconds + ) { + this.secretKey = secretKey; + this.validityInMilliseconds = validityInMilliseconds; + } + + public String createToken(Member member) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setSubject(member.getId().toString()) + .claim(MEMBER_ID_KEY, member.getId()) + .setExpiration(validity) + .signWith(Keys.hmacShaKeyFor(secretKey.getBytes())) + .compact(); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthClient.java b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthClient.java new file mode 100644 index 00000000..ccb2c1a3 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthClient.java @@ -0,0 +1,77 @@ +package woowacourse.touroot.authentication.infrastructure; + +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import woowacourse.touroot.authentication.dto.KakaoAccessTokenResponse; +import woowacourse.touroot.authentication.dto.OauthUserInformationResponse; + +@Component +public class KakaoOauthClient { + + private final String userInformationRequestUri; + private final String accessTokenRequestUri; + private final String restApiKey; + private final String redirectUri; + private final RestClient restClient; + + public KakaoOauthClient( + @Value("${oauth.kakao.user-information-request-uri}") String userInformationRequestUri, + @Value("${oauth.kakao.access-token-request-uri}") String accessTokenRequestUri, + @Value("${oauth.kakao.rest-api-key}") String restApiKey, + @Value("${oauth.kakao.redirect-uri}") String redirectUri + ) { + this.userInformationRequestUri = userInformationRequestUri; + this.accessTokenRequestUri = accessTokenRequestUri; + this.restApiKey = restApiKey; + this.redirectUri = redirectUri; + this.restClient = buildRestClient(); + } + + private RestClient buildRestClient() { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(Duration.ofSeconds(1)) + .withReadTimeout(Duration.ofSeconds(3)); + + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } + + public OauthUserInformationResponse requestUserInformation(String authorizationCode) { + KakaoAccessTokenResponse kakaoAccessTokenResponse = requestAccessToken(authorizationCode); + + return restClient.get() + .uri(userInformationRequestUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessTokenResponse.accessToken()) + .retrieve() + .toEntity(OauthUserInformationResponse.class) + .getBody(); + } + + private KakaoAccessTokenResponse requestAccessToken(String authorizationCode) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", authorizationCode); + params.add("client_id", restApiKey); + params.add("redirect_uri", redirectUri); + params.add("grant_type", "authorization_code"); + + return restClient.post() + .uri(accessTokenRequestUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .toEntity(KakaoAccessTokenResponse.class) + .getBody(); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthProvider.java b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthProvider.java new file mode 100644 index 00000000..d23b7de7 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/infrastructure/KakaoOauthProvider.java @@ -0,0 +1,16 @@ +package woowacourse.touroot.authentication.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import woowacourse.touroot.authentication.dto.OauthUserInformationResponse; + +@RequiredArgsConstructor +@Component +public class KakaoOauthProvider { + + private final KakaoOauthClient kakaoOauthClient; + + public OauthUserInformationResponse getUserInformation(String authorizationCode) { + return kakaoOauthClient.requestUserInformation(authorizationCode); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/authentication/service/LoginService.java b/backend/src/main/java/woowacourse/touroot/authentication/service/LoginService.java new file mode 100644 index 00000000..9f02b4e7 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/authentication/service/LoginService.java @@ -0,0 +1,33 @@ +package woowacourse.touroot.authentication.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import woowacourse.touroot.authentication.dto.LoginResponse; +import woowacourse.touroot.authentication.dto.OauthUserInformationResponse; +import woowacourse.touroot.authentication.infrastructure.JwtTokenProvider; +import woowacourse.touroot.authentication.infrastructure.KakaoOauthProvider; +import woowacourse.touroot.member.domain.Member; +import woowacourse.touroot.member.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +public class LoginService { + + private final MemberRepository memberRepository; + private final KakaoOauthProvider oauthProvider; + private final JwtTokenProvider tokenProvider; + + public LoginResponse login(String code) { + OauthUserInformationResponse userInformation = oauthProvider.getUserInformation(code); + Member member = memberRepository.findByKakaoId(userInformation.socialLoginId()) + .orElseGet(() -> signUp(userInformation)); + + return new LoginResponse(tokenProvider.createToken(member)); + } + + private Member signUp(OauthUserInformationResponse userInformation) { + return memberRepository.save( + new Member(userInformation.socialLoginId(), userInformation.nickname(), userInformation.profileImage()) + ); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/global/config/JasyptConfig.java b/backend/src/main/java/woowacourse/touroot/global/config/JasyptConfig.java new file mode 100644 index 00000000..2e9779c8 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/global/config/JasyptConfig.java @@ -0,0 +1,9 @@ +package woowacourse.touroot.global.config; + +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableEncryptableProperties +public class JasyptConfig { +} diff --git a/backend/src/main/java/woowacourse/touroot/member/domain/Member.java b/backend/src/main/java/woowacourse/touroot/member/domain/Member.java new file mode 100644 index 00000000..898f5e87 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/member/domain/Member.java @@ -0,0 +1,36 @@ +package woowacourse.touroot.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long kakaoId; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private String profileImageUri; + + public Member(Long kakaoId, String nickname, String profileImageUri) { + this(null, kakaoId, nickname, profileImageUri); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/member/repository/MemberRepository.java b/backend/src/main/java/woowacourse/touroot/member/repository/MemberRepository.java new file mode 100644 index 00000000..fe748797 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/member/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package woowacourse.touroot.member.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.member.domain.Member; + +public interface MemberRepository extends JpaRepository { + + Optional findByKakaoId(Long kakaoId); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ea33582a..abccd82d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,3 +1,18 @@ +oauth: + kakao: + user-information-request-uri: https://kapi.kakao.com/v2/user/me + access-token-request-uri: https://kauth.kakao.com/oauth/token + rest-api-key: ENC(i6J7NWUsDpXVbJrbSUcNFI3h0oc6v8PxuHShU9UA7EVuUNLtQN/ANII+8j5HjhGO) +jasypt: + encryptor: + algorithm: PBEWithMD5AndDES + iv-generator-classname: org.jasypt.iv.NoIvGenerator +--- +security: + jwt: + token: + secret-key: ENC(SNwFG2NQDZkmIK3nNoZFdwQ0ZxKuoe+qcw10ljdW941YEx/Qky9PEEl+wvAN9S1KR26D3a83SnU=) + expire-length: 1800000 spring: config: activate: @@ -18,7 +33,15 @@ spring: hibernate: ddl-auto: create-drop defer-datasource-initialization: true +oauth: + kakao: + redirect-uri: http://localhost:8080/api/v1/login/oauth/kakao --- +security: + jwt: + token: + secret-key: ENC(L36WWjoZtP2nHHkqxDGlYLsMHMp+EBL2Fnl+X2de2KHk+PIfViyVM7rCYcbcFpo7yB4MaP++atU=) + expire-length: 1800000 spring: config: activate: @@ -28,9 +51,9 @@ spring: enabled: false datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} + url: ENC(FgbVXdH4a5/EkMxdmYfPhFKAOwn1w+/CnmWDcN9p6fOVP6mP9coMEYezCPCNf95h) + username: ENC(SJznQPqjlZuw3qf8kv9IJQ==) + password: ENC(HsOo6wWp//egPPsSG6Wf40eF1Q2sVKfGuH4zGTL81Mw=) jpa: show-sql: true properties: @@ -39,27 +62,6 @@ spring: dialect: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: none ---- -server: - port: 8081 -spring: - config: - activate: - on-profile: test - datasource: - url: jdbc:h2:mem:test - h2: - console: - enabled: true - path: /h2-console - jpa: - show-sql: true - properties: - hibernate: - format_sql: true - hibernate: - ddl-auto: create - defer-datasource-initialization: true - sql: - init: - mode: never +oauth: + kakao: + redirect-uri: http://api-dev.touroot.kr/api/v1/login/oauth/kakao diff --git a/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java b/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java deleted file mode 100644 index 8dde8ee9..00000000 --- a/backend/src/test/java/woowacourse/touroot/TourootApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package woowacourse.touroot; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TourootApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java b/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java index 2cd03d48..d40fb01b 100644 --- a/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java +++ b/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java @@ -4,10 +4,10 @@ import java.lang.annotation.RetentionPolicy; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@ActiveProfiles("test") @Retention(RetentionPolicy.RUNTIME) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) public @interface AcceptanceTest { } diff --git a/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java b/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java index 264e6f63..ed2d2125 100644 --- a/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java +++ b/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java @@ -7,11 +7,9 @@ import jakarta.persistence.metamodel.EntityType; import jakarta.transaction.Transactional; import java.util.List; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Component -@Profile("test") public class DatabaseCleaner { public static final String CAMEL_CASE = "([a-z])([A-Z])"; diff --git a/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java b/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java index 98625352..7f2aaac9 100644 --- a/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java +++ b/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java @@ -1,7 +1,6 @@ package woowacourse.touroot.utils; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import woowacourse.touroot.place.domain.Place; import woowacourse.touroot.place.repository.PlaceRepository; @@ -23,7 +22,6 @@ import java.time.LocalDate; @Component -@Profile("test") public class TestFixture { @Autowired @@ -37,7 +35,7 @@ public class TestFixture { @Autowired TraveloguePhotoRepository traveloguePhotoRepository; - + @Autowired private PlaceRepository placeRepository; diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 00000000..858d7497 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,31 @@ +oauth: + kakao: + user-information-request-uri: https://kapi.kakao.com/v2/user/me + access-token-request-uri: https://kauth.kakao.com/oauth/token + redirect-uri: http://localhost:8080/api/v1/login/oauth/kakao + rest-api-key: test-api-key +security: + jwt: + token: + secret-key: test-TADG67STFSDAGSDFSG4567UTKJHFHSDFGSR231DF + expire-length: 1800000 +server: + port: 8081 +spring: + datasource: + url: jdbc:h2:mem:test + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + defer-datasource-initialization: true + sql: + init: + mode: never