Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - 카카오 로그인 구현 #55

Merged
merged 23 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2583cc9
feat: OAuth 요청에 대한 응답 DTO 작성
Libienz Jul 17, 2024
63ccece
chore: Jasypt 의존성 추가
Libienz Jul 18, 2024
778d1b6
feat: 카카오 OAuth Client 객체 구현
Libienz Jul 18, 2024
11684f3
feat: OAuthProvider 구현
Libienz Jul 18, 2024
ce2e4e0
feat: Login 서비스, 컨트롤러 구현
Libienz Jul 18, 2024
b0a0b00
feat: 소셜 로그인 회원가입 분기 흐름 구현
nak-honest Jul 18, 2024
20198e3
feat: jwt를 통한 로그인 기능 구현
slimsha2dy Jul 18, 2024
4b52f2c
chore: 디버깅 출력 문구 삭제
slimsha2dy Jul 18, 2024
8e979a2
chore: 머지 충돌 해결
Libienz Jul 18, 2024
4535fc0
fix: jasypt 시크릿 키를 github action에서 환경 변수로 지정
nak-honest Jul 18, 2024
c265ab2
fix: github action에서 빌드 시 jasypt secret key를 환경변수로 받도록 변경
nak-honest Jul 18, 2024
c03e945
feat: 카카오 OAuth 로그인 redirect uri 프로파일별로 분리
nak-honest Jul 19, 2024
554e846
fix: 테스트 용 config yml 파일 분리 및 테스트에서 jasypt 제거
Libienz Jul 19, 2024
5d6f4c0
chore: 로컬용 jwt 비밀키와 개발 서버용 키 분리
Libienz Jul 19, 2024
c42b09e
chore: 데이터베이스 정보 관리 환경변수 방식에서 jasypt 방식으로 변경
Libienz Jul 19, 2024
859fce1
refactor: DTO inner 클래스 가독성 위해서 별도의 record로 분리
Libienz Jul 19, 2024
efa9892
refactor: 카카오 유저 정보 응답 DTO nested record로 개선
Libienz Jul 19, 2024
f4406a4
style: 괄호 재배치, 공백 문자 가독성 개선
Libienz Jul 19, 2024
deff84e
refactor: RestClient 설정 기능 생성자에서 분리 개선
Libienz Jul 19, 2024
b9ec188
refactor: 하드 코딩된 헤더 정보 미리 제공되는 상수로 변경
Libienz Jul 19, 2024
2b4913b
refactor: 사용되지 않는 생성자 접근 제어 레벨 개선
Libienz Jul 19, 2024
62049b0
chore: github action에서 빌드 시 환경 변수를 지정하지 않도록 변경
nak-honest Jul 19, 2024
46bcaac
Merge branch 'develop/be' into feature/be/#47
Libienz Jul 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public JwtTokenProvider(
public JwtTokenProvider(
@Value("${security.jwt.token.secret-key}") String secretKey,
@Value("${security.jwt.token.expire-length}") long validityInMilliseconds
) {
this.secretKey = secretKey;
this.validityInMilliseconds = validityInMilliseconds;
}

괄호 내려주면 다른 파일이랑 컨벤션이 더 잘 맞을 것 같습니다

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영 완!

@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() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

걍 의견) build말고 getRestClient 이런 네이밍이 더 직관적일 수도 있을 것 같네용?

ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자 로직이 많은 것 같아서 메서드로 빼면 어떨깝쇼

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋을것 같답쇼 분리했습니답쇼

.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<>();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MutliValueMap 사용하신 특별한 이유가 있나요? 지금 코드만 보면 key - value 한 쌍씩 이뤄지는 것 같아서 궁금해용

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In cases where our method parameter is of a type MultiValueMap, we can use either the @RequestParam or @RequestBody annotation to bind it appropriately with the body of the HTTP request. That’s because the Servlet API combines the query parameters and form data into a single map called parameters, and that includes automatic parsing of the request body:

html form 데이터가 안정적으로 body에 바인딩 되기 위해서는 Spring에서 util로 제공하는 MultivalueMap 을 이용해야 하는 것으로 알고 있습니다.
관련 링크 첨부드려요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

신기하네요

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
Loading