Skip to content

Commit

Permalink
[Feature] - 카카오 로그인 구현 (#55)
Browse files Browse the repository at this point in the history
* feat: OAuth 요청에 대한 응답 DTO 작성

* chore: Jasypt 의존성 추가

* feat: 카카오 OAuth Client 객체 구현

* feat: OAuthProvider 구현

* feat: Login 서비스, 컨트롤러 구현

* feat: 소셜 로그인 회원가입 분기 흐름 구현

* feat: jwt를 통한 로그인 기능 구현

* chore: 디버깅 출력 문구 삭제

* fix: jasypt 시크릿 키를 github action에서 환경 변수로 지정

* fix: github action에서 빌드 시 jasypt secret key를 환경변수로 받도록 변경

- `build.gradle`에 테스트 시 jasypt secret key를 환경변수로 지정한다.
- `be-cd-dev.yml`와 `be-ci.yml`에서 -P 옵션으로 jasypt secret key를 환경변수로 지정한다.

* feat: 카카오 OAuth 로그인 redirect uri 프로파일별로 분리

* fix: 테스트 용 config yml 파일 분리 및 테스트에서 jasypt 제거

* chore: 로컬용 jwt 비밀키와 개발 서버용 키 분리

* chore: 데이터베이스 정보 관리 환경변수 방식에서 jasypt 방식으로 변경

* refactor: DTO inner 클래스 가독성 위해서 별도의 record로 분리

* refactor: 카카오 유저 정보 응답 DTO nested record로 개선

* style: 괄호 재배치, 공백 문자 가독성 개선

* refactor: RestClient 설정 기능 생성자에서 분리 개선

* refactor: 하드 코딩된 헤더 정보 미리 제공되는 상수로 변경

* refactor: 사용되지 않는 생성자 접근 제어 레벨 개선

* chore: github action에서 빌드 시 환경 변수를 지정하지 않도록 변경

---------

Co-authored-by: libienz <[email protected]>
Co-authored-by: nhlee98 <[email protected]>
Co-authored-by: 이낙헌 <[email protected]>
  • Loading branch information
4 people authored Jul 19, 2024
1 parent 062c37e commit d07aa12
Show file tree
Hide file tree
Showing 18 changed files with 362 additions and 47 deletions.
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LoginResponse> login(@RequestParam(name = "code") String authorizationCode) {
return ResponseEntity.ok()
.body(loginService.login(authorizationCode));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package woowacourse.touroot.authentication.dto;

public record LoginResponse(String accessToken) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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())
);
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Member, Long> {

Optional<Member> findByKakaoId(Long kakaoId);
}
56 changes: 29 additions & 27 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Loading

0 comments on commit d07aa12

Please sign in to comment.