카카오클라우드스쿨 인 제주
[프로젝트] 카카오 로그인 OAuth2 구현
끼끼 ・ 2024. 8. 14. 23:16
URL 복사 이웃추가
본문 기타 기능
신고하기
블로그랑 다른 팀원 분들의 도움을 받고 구현한 카카오 로그인
보안을 위해 가장 상위의 위치에 .env 파일 만들어서 kakao-client 값을 넣었고 .gitignore에 .env를 포함시켰다
카카오 클라이언트 값은 REST API 키를 넣는건데, 이걸 구하는 방법은 카카오 디벨로퍼 애플리케이션을 먼저 새로 만들어야 한다 그리고 내 애플리케이션 -> 앱 -> 앱 키 순으로 가면 키 값이 나온다
필요한 키를 복사해서 .env 파일에 PRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID=${REST API 키}를 입력했다
키 값은 어디에도 유출되면 안 되므로 아주 조심해야 한다
카카오 인증 절차를 거치려면 이메일 동의를 받아야되는데, 동의를 받기 위해서 애플리케이션을 비즈니스 앱으로 전환해야 한다
비즈니스 앱으로 전환하는 방법은 프로필 사진 아무거나 등록하면 되었다
plugins {id 'java'id 'org.springframework.boot' version '3.2.9-SNAPSHOT'id 'io.spring.dependency-management' version '1.1.6'}group = 'com.example'version = '0.0.1-SNAPSHOT'java {toolchain {languageVersion = JavaLanguageVersion.of(17)}}configurations {compileOnly {extendsFrom annotationProcessor}}repositories {mavenCentral()maven { url 'https://repo.spring.io/snapshot' }}dependencies {implementation 'org.springframework.boot:spring-boot-starter-web'compileOnly 'org.projectlombok:lombok'developmentOnly 'org.springframework.boot:spring-boot-devtools'annotationProcessor 'org.projectlombok:lombok'implementation 'org.springframework.boot:spring-boot-starter-data-jpa'implementation 'org.springframework.boot:spring-boot-starter-webflux'runtimeOnly 'com.mysql:mysql-connector-j'testImplementation 'org.springframework.boot:spring-boot-starter-test'testRuntimeOnly 'org.junit.platform:junit-platform-launcher'implementation 'io.github.cdimascio:dotenv-java:2.2.0'// 카카오 로그인에 필요한 의존성 주입implementation 'org.springframework.boot:spring-boot-starter-security'implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'}tasks.named('test') {useJUnitPlatform()}
application.yml
spring: application: name: sumda profiles: active: local include: my datasource: url: jdbc:mysql://localhost:3306/sumda # MySQL 사용 username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 드라이버 클래스 네임 수정 jpa: hibernate: ddl-auto: none # 스키마 자동 생성 옵션을 none으로 설정 show-sql: true database-platform: org.hibernate.dialect.MySQLDialect # MySQL Dialect로 변경 security: oauth2: client: registration: kakao: client_id: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID:default_client_id} provider: kakao client-authentication-method: POST authorization-grant-type: authorization_code redirect-uri: "http://localhost:3000/auth" scope: profile_nickname, profile_image, account_email client-name: Kakao provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id
Application (메인)
package com.example.sumda;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import io.github.cdimascio.dotenv.Dotenv;@SpringBootApplicationpublic class SumdaApplication {public static void main(String[] args) {Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();System.out.println("KAKAO_CLIENT_ID: " + dotenv.get("SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID"));SpringApplication.run(SumdaApplication.class, args);}}
controller 패키지
KakaoLoginController
package com.example.sumda.controller;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;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 com.example.sumda.dto.KakaoUserInfoResponseDto;import com.example.sumda.service.KakaoService;import java.io.IOException;@Slf4j@RestController@RequiredArgsConstructor@RequestMapping("")public class KakaoLoginController { private final KakaoService kakaoService; @GetMapping("/callback") public ResponseEntity<?> callback(@RequestParam("code") String code) throws IOException { String accessToken = kakaoService.getAccessTokenFromKakao(code); KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); // User 로그인, 또는 회원가입 로직 추가 return new ResponseEntity<>(HttpStatus.OK); }}
KakaoLoginPageController
package com.example.sumda.controller;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;@Controller@RequestMapping("/login")public class KakaoLoginPageController { @Value("${spring.security.oauth2.client.registration.kakao.client_id}") private String client_id; @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") private String redirect_uri; @GetMapping("/page") public String loginPage(Model model) { String location = "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id="+client_id+"&redirect_uri="+redirect_uri; model.addAttribute("location", location); return "login"; }}
dto 패키지
KakaoTokenResponseDto
package com.example.sumda.dto;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import com.fasterxml.jackson.annotation.JsonProperty;import lombok.Getter;import lombok.NoArgsConstructor;@Getter@NoArgsConstructor //역직렬화를 위한 기본 생성자@JsonIgnoreProperties(ignoreUnknown = true)public class KakaoTokenResponseDto { @JsonProperty("token_type") public String tokenType; @JsonProperty("access_token") public String accessToken; @JsonProperty("id_token") public String idToken; @JsonProperty("expires_in") public Integer expiresIn; @JsonProperty("refresh_token") public String refreshToken; @JsonProperty("refresh_token_expires_in") public Integer refreshTokenExpiresIn; @JsonProperty("scope") public String scope;}
KakaoUserInfoResponseDto
package com.example.sumda.dto;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import com.fasterxml.jackson.annotation.JsonProperty;import lombok.Getter;import lombok.NoArgsConstructor;import java.util.Date;import java.util.HashMap;@Getter@NoArgsConstructor //역직렬화를 위한 기본 생성자@JsonIgnoreProperties(ignoreUnknown = true)public class KakaoUserInfoResponseDto { //회원 번호 @JsonProperty("id") public Long id; //자동 연결 설정을 비활성화한 경우만 존재. //true : 연결 상태, false : 연결 대기 상태 @JsonProperty("has_signed_up") public Boolean hasSignedUp; //서비스에 연결 완료된 시각. UTC @JsonProperty("connected_at") public Date connectedAt; //카카오싱크 간편가입을 통해 로그인한 시각. UTC @JsonProperty("synched_at") public Date synchedAt; //사용자 프로퍼티 @JsonProperty("properties") public HashMap<String, String> properties; //카카오 계정 정보 @JsonProperty("kakao_account") public KakaoAccount kakaoAccount; //uuid 등 추가 정보 @JsonProperty("for_partner") public Partner partner; @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoAccount { //프로필 정보 제공 동의 여부 @JsonProperty("profile_needs_agreement") public Boolean isProfileAgree; //닉네임 제공 동의 여부 @JsonProperty("profile_nickname_needs_agreement") public Boolean isNickNameAgree; //프로필 사진 제공 동의 여부 @JsonProperty("profile_image_needs_agreement") public Boolean isProfileImageAgree; //사용자 프로필 정보 @JsonProperty("profile") public Profile profile; //이름 제공 동의 여부 @JsonProperty("name_needs_agreement") public Boolean isNameAgree; //카카오계정 이름 @JsonProperty("name") public String name; //이메일 제공 동의 여부 @JsonProperty("email_needs_agreement") public Boolean isEmailAgree; //이메일이 유효 여부 // true : 유효한 이메일, false : 이메일이 다른 카카오 계정에 사용돼 만료 @JsonProperty("is_email_valid") public Boolean isEmailValid; //이메일이 인증 여부 //true : 인증된 이메일, false : 인증되지 않은 이메일 @JsonProperty("is_email_verified") public Boolean isEmailVerified; //카카오계정 대표 이메일 @JsonProperty("email") public String email; //연령대 제공 동의 여부 @JsonProperty("age_range_needs_agreement") public Boolean isAgeAgree; //연령대 //참고 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info @JsonProperty("age_range") public String ageRange; //출생 연도 제공 동의 여부 @JsonProperty("birthyear_needs_agreement") public Boolean isBirthYearAgree; //출생 연도 (YYYY 형식) @JsonProperty("birthyear") public String birthYear; //생일 제공 동의 여부 @JsonProperty("birthday_needs_agreement") public Boolean isBirthDayAgree; //생일 (MMDD 형식) @JsonProperty("birthday") public String birthDay; //생일 타입 // SOLAR(양력) 혹은 LUNAR(음력) @JsonProperty("birthday_type") public String birthDayType; //성별 제공 동의 여부 @JsonProperty("gender_needs_agreement") public Boolean isGenderAgree; //성별 @JsonProperty("gender") public String gender; //전화번호 제공 동의 여부 @JsonProperty("phone_number_needs_agreement") public Boolean isPhoneNumberAgree; //전화번호 //국내 번호인 경우 +82 00-0000-0000 형식 @JsonProperty("phone_number") public String phoneNumber; //CI 동의 여부 @JsonProperty("ci_needs_agreement") public Boolean isCIAgree; //CI, 연계 정보 @JsonProperty("ci") public String ci; //CI 발급 시각, UTC @JsonProperty("ci_authenticated_at") public Date ciCreatedAt; @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class Profile { //닉네임 @JsonProperty("nickname") public String nickName; //프로필 미리보기 이미지 URL @JsonProperty("thumbnail_image_url") public String thumbnailImageUrl; //프로필 사진 URL @JsonProperty("profile_image_url") public String profileImageUrl; //프로필 사진 URL 기본 프로필인지 여부 //true : 기본 프로필, false : 사용자 등록 @JsonProperty("is_default_image") public String isDefaultImage; //닉네임이 기본 닉네임인지 여부 //true : 기본 닉네임, false : 사용자 등록 @JsonProperty("is_default_nickname") public Boolean isDefaultNickName; } } @Getter @NoArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class Partner { //고유 ID @JsonProperty("uuid") public String uuid; }}
entity 패키지
User
package com.example.sumda.entity;import jakarta.persistence.*;import lombok.AllArgsConstructor;import lombok.Getter;import lombok.NoArgsConstructor;import lombok.Setter;@Entity@Table(name = "user")@Getter@Setter@NoArgsConstructor@AllArgsConstructorpublic class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long kakaoId; private String nickname; private String profileImageUrl;}
repository 패키지
UserRepository
package com.example.sumda.repository;import org.springframework.data.jpa.repository.JpaRepository;import com.example.sumda.entity.User;import org.springframework.stereotype.Repository;@Repositorypublic interface UserRepository extends JpaRepository<User, Long>{}
service 패키지
KakaoService
package com.example.sumda.service;import io.netty.handler.codec.http.HttpHeaderValues;import jakarta.transaction.Transactional;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.HttpHeaders;import org.springframework.http.HttpStatusCode;import org.springframework.stereotype.Service;import com.example.sumda.dto.KakaoTokenResponseDto;import com.example.sumda.dto.KakaoUserInfoResponseDto;import com.example.sumda.entity.User;import com.example.sumda.repository.UserRepository;import org.springframework.web.reactive.function.client.WebClient;import reactor.core.publisher.Mono;@Slf4j@Servicepublic class KakaoService { private final String clientId; private final String KAUTH_TOKEN_URL_HOST; private final String KAUTH_USER_URL_HOST; private final UserRepository userRepository; // 생성자 주입 방식으로 변경 public KakaoService(@Value("${spring.security.oauth2.client.registration.kakao.client_id}") String clientId, UserRepository userRepository) { this.clientId = clientId; this.userRepository = userRepository; this.KAUTH_TOKEN_URL_HOST = "https://kauth.kakao.com"; this.KAUTH_USER_URL_HOST = "https://kapi.kakao.com"; } public String getAccessTokenFromKakao(String code) { KakaoTokenResponseDto kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post() .uri(uriBuilder -> uriBuilder .scheme("https") .path("/oauth/token") .queryParam("grant_type", "authorization_code") .queryParam("client_id", clientId) .queryParam("code", code) .build(true)) .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) .bodyToMono(KakaoTokenResponseDto.class) .block(); log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken()); log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken()); log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken()); log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope()); return kakaoTokenResponseDto.getAccessToken(); } @Transactional public KakaoUserInfoResponseDto getUserInfo(String accessToken) { KakaoUserInfoResponseDto userInfo = WebClient.create(KAUTH_USER_URL_HOST) .get() .uri(uriBuilder -> uriBuilder .scheme("https") .path("/v2/user/me") .build(true)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) .bodyToMono(KakaoUserInfoResponseDto.class) .block(); log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId()); log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName()); log.info("[ Kakao Service ] ProfileImageUrl ---> {} ", userInfo.getKakaoAccount().getProfile().getProfileImageUrl()); User user = new User(); user.setKakaoId(userInfo.getId()); user.setNickname(userInfo.getKakaoAccount().getProfile().getNickName()); user.setProfileImageUrl(userInfo.getKakaoAccount().getProfile().getProfileImageUrl()); userRepository.save(user); return userInfo; }}
카카오 로그인 구현한 화면
댓글쓰기 이 글에 댓글 단 블로거 열고 닫기
인쇄