WEB

OAuth [JWT] 로그인 인증 시스템 구축

데일리코딩 2024. 2. 13. 16:13

# 1 OAuth JWT 소셜 로그인 

1. 소셜로그인 3가지 카카오, 네이버, 구글

# 개발환경

백엔드: spring-boot 2.6.4

maven

프론트엔드: react

DB: postgreSQL

카카오 소셜로그인 OAuth 구현하기

### 카카오 소셜로그인 전체 프로세스

1. 카카오 Developer 관련 api 및 관련 문서
해당 링크 : https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

2. 카카오 콘솔에 필요한 내용 설정

3. 개발 환경 구성

4. 초기화 및 로그인 구현


# 2.  카카오 콘솔에 필요한 내용설정

1. 애플리케이션 등록

> 링크로 이동 ->  https://developers.kakao.com/console/app

 

 

카카오계정

 

accounts.kakao.com

> "애플리케이션 추가하기" 버튼 클릭

* 앱 이름 과 사업자명은 필수로 등록

 

 


 

2. 카카오 로그인 설정

1) 카카오 로그인 활성화

> 내 애플리케이션 -> 제품 설정 -> 카카오 로그인

> 카카오 로그인 활성화

 

3. Web 플랫폼 등록

> 내 애플리케이션 -> 앱 설정 -> 플랫폼

카카오 로그인 사용할 클라이언트 도메인 주소를 입력하는 항목

로컬환경에서 테스트 예정인 경우 (http://localhost:8080) 또는 (http://127.0.0.1:8080) 

포트번호는 할당된 포트번호에 맞춰 설정하면 된다.

> 도메인 입력 후 저장

 

4. Redirect URL 등록

> 내 어플리케이션 -> 제품설정 -> 카카오 로그인

Redirect URL 경우 카카오에서 제공해주는 링크를 통해 로그인 성공 시
우리가 입력한 Redirect URL로 http://kakaoLogin?code=4Fd_fd3EQc

이런식으로 kakaoToken을 받을 수 있는 code 값을 전달해준다.

 

5. 동의항목 설정

> 내 어플리케이션 -> 제품 설정 -> 카카오 로그인

동의항목을 통해서 로그인 하려는 유저에게 받아내고자 하는 데이터를 선택해서 가져올 수 있다.

각 플랫폼 마다 개인정보를 취급하는 조건이 다르므로 잘 확인해서 가져오도록 하며

만약 응답데이터로 받을 데이터를 선택동의로 했을 시 서버에서 null check를 꼭 확인해야한다.

꼭 받아야하는 응답데이터로는 상태를 필수 동의로 설정한다(* 필수동의를 하지 않으면 로그인 불가 )

 

6. API 키 체크

> 내 어플리케이션 -> 앱 설정 -> 앱 키

해당 화면에 들어가서 API 키를 확인해서 문서대로 잘 코드를 작성하면 된다.

 


# 1. 카카오 소셜 로그인 인증 JWT 기능 구현

소셜 로그인 인증 시스템 프로세스

1. 카카오 제공 링크를 통해 카카오 로그인 화면창 띄위기

2. RedirectURl 페이지 통해 kakao code 값을 받기

3. 서버에서 code값을 통해 kakao 의 Acess Token 받아오기

4. Acess Token 통해 kakao 유저 정보 가져오기

5. JWT 토큰 생성

6. 기존 유저일 시 로그인 통과 후 jwt 토큰 발급

7. 기존 유저가 아닐 시 회원가입 후 jwt 토큰발급

8. 로그인 요청 외에 토큰 검증하는 인터셉터 기능 구현하여 해당 요청이 권한 확인 

 

# 2. 실제 구현 코드 및 설명 

개발환경은 클라이언트 단은 react 서버는 spring-boot로 구성하였고

DB 저장은 postgreSQL로 진행하였다.

 

1. 카카오 제공 링크를 통해 카카오 로그인 화면창 띄우기 

먼저 카카오에 로그인 요청 URL은 다음과 같다.

https://kauth.kakao.com/oauth/authorize?
	response_type=code
	&client_id=${REST_API_KEY}
	&redirect_uri=${REDIRECT_URI}
	&scope=account_email profile_nickname profile_image`;
  • response_type : code  // (code) 라고 값을 고정하면 된다.
  • client_id : 카카오 앱 등록시 발급 받은 api_key // (위에서 받은 REST_API)
  • redirect_uri : 카카오 인가받기 요청후 코드값 전달 받을 URI
  • scope : 동의항목에서 설정한 이후 받고 싶은 데이터

2. react 버튼 화면 구현

버튼을 구현하고 해당 버튼 클릭 시 새로운 윈도우 창을 띄우고 해당 URL로 이동하게 코드를 작성했다.

import React from "react"

const SocialLogin = () => {

	// REST_API_KEY 변수는 .env 파일에 저장 후 gitignore 파일에 등록 할것

	const kakaoLogin = () => {
		const kakaoURL = https://kauth.kakao.com/oauth/authorize?
		response_type=code
		&client_id=${REST_API_KEY}
		&redirect_uri=${REDIRECT_URI}
		&scope=account_email profile_nickname profile_image`;
		window.location.href = kakaoURL;
	}

	return (
	<button onClick={kakaoLogin}> 카카오 로그인 <button>
	)
}

 


 # 2. RedirectURl 페이지 통해 kakao code 값을 받기

1. react-router-dom 을 통해서 라우팅을 설정한다.

관련 공식문서 : https://reactrouter.com/en/main

Redirect URL 경로를 설정하여 로그인 요청 후 받을 code 페이지를 라우팅 합니다.

 

Home v6.22.0 | React Router

I'm on v5 The migration guide will help you migrate incrementally and keep shipping along the way. Or, do it all in one yolo commit! Either way, we've got you covered to start using the new features right away.

reactrouter.com

import { createBrowserRouter, RouterProvider } from "react-router-dom";


const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/kakaoLogin",
    element: <KakaoLogin />,
  }
]);

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <RouterProvider router={router} />
);

 

 

 

2. kakaoLogin 라우팅이 설정된 화면에서 kakao 서버에서 받은 code 값을 우리 서버측에게 전달하기

1. queryParams 에는 파라미터를 가져오고 kakaoCode 변수에는 "code="의 값을 가져와 저장합니다.

2. 받은 code 값을 /api/kakaoLogin body에 담아 서버에 전달합니다.

3. data 변수 에는 위 모든 프로세스를 거친 후 플랫폼 자체에서 제공하는 jwt 토큰을 응답 받습니다.

import React, { useEffect } from "react";
import axios from "axios";

const KakaoLogin = () => {
  const navigate = useNavigate();
  const queryParams = new URLSearchParams(window.location.search);
  const KakaoCode = queryParams.get("code");

  const sendValue = {
    code: KakaoCode,
  };

  const postCodeToServer = async () => {
    const response = await axios.post(
      "http://localhost:8080/api/kakaoLogin",
      sendValue
    );

    const data = await response.data;
    console.log("data from server = >", data);
  };

  useEffect(() => {
    postCodeToServer();
  }, []);
};

export default KakaoLogin;

 


# 3.  서버에서 code값을 통해 kakao 의 Acess Token 받아오기

토큰 요청 URL 은 다음과 같습니다.

String kakaoTokenUrl = "https://kauth.kakao.com/oauth/token"

 

2. header 객체와 bodyMap 객체를 생성하여 카카오 서버에 요청하여 토큰을 응답 받습니다.

RequestBody 요청 바디는 kakaoLogin 객체에 매핑되어 code값을 가지고 들어옵니다.

매핑된 객체의 getCode 메소드를 통해 클라이언트에게 전달 받은 code값으로 토큰을 요청합니다.

@PostMapping("/api/kakaoLogin")
public ResponseEntity kakaoLogin (@RequestBody KakaoLogin kakaoLogin) {

  MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
  parameters.add("grant_type", "authorization_code");
  parameters.add("client_id", kakaoClientId);
  parameters.add("redirect_uri", kakaoRedirectUrl);
  parameters.add("code", kakaoLogin.getCode());

  HttpHeaders headers = new HttpHeaders();
  headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

  HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(parameters, headers);
  ResponseEntity<String> response = new RestTemplate().postForEntity(kakaoTokenUrl, request, String.class);

}
  • grant_type : 인증 요청 타입
  • client_id : 플랫폼에서 발급 받은 인증키
  • redirect_uri : callBackURL
  • code : callBackURL 에서 전달받은 코드 값

code값에 문제가 없을 시

response : 긴 문자열로 응답이 옵니다.

tokenResponse = 
{
"access_token":"4sxhnSawpO9aRJm3Xw5wcRt1R9xktO9JkvcKKcjZAAABjaBq27lHueF-5ScOZw",
"token_type":"bearer","refresh_token":"UbHOpSP8lrBHs_PfESZ7ot4kUpcT44_BdgUKKcjZAAABjaBq27ZHueF-5ScOZw",
"expires_in":21599,
"scope":
   "account_email
    profile_image 
    talk_message
    profile_nickname",
"refresh_token_expires_in":5183999
}

 


# 4. Acess Token 통해 kakao 유저 정보 가져오기

1. kakao 에서 응답받은 유저 정보를 객체로 받기 위해 KakaoUserInfo 객체를 생성

2. 카카오 서버에서 응답으로 받은 AcessToken 을 header 객체에 담기

3. 카카오 요청하여 사용자 정보 응답으로 받기

4. 응답받은 사용자 정보 반환 

public KakaoUserInfo getKakaoUserInfo(String accessToken) {
        // RestTemplate 객체 생성
        RestTemplate restTemplate = new RestTemplate();
        String kakaoApiUrl = "https://kapi.kakao.com";

        // HTTP 요청 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);

        // 카카오 API 엔드포인트 설정
        String apiUrl = kakaoApiUrl + "/v2/user/me?target_id_type=user_id&target_id=1";

        // HTTP 요청 보내기
        ResponseEntity<KakaoUserInfo> response = restTemplate.exchange(
                apiUrl,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                KakaoUserInfo.class
        );

        // HTTP 응답 결과에서 회원 정보 추출
        KakaoUserInfo userInfo = response.getBody();
        return userInfo;
    }

 


# 5. JWT 토큰 생성

1. maven 환경이므로 해당 라이브러리 추가 

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency><dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>

 

2. 토큰 생성하는 코드 작성

Json 형태의 데이터를 claim 함수를 통해 key, value 형식으로 데이터를 입력하면 해당 유저의 고유한 token 값을 생성할 수 있다.

public class JwtTokenGenerator {

    private static final long EXPIRATION_TIME = 12 * 3600 * 1000; // 토큰 만료 시간 (12시간)

    public String generateToken(String username, String email, String loginType, String secretKeyString) {

        Date now = new Date();
        Date expirationDate = new Date(now.getTime() + EXPIRATION_TIME);

        SecretKey secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes(StandardCharsets.UTF_8));

        String token = Jwts.builder()
                .setSubject(username)
                .claim("email", email)
                .claim("loginType", loginType)
                .claim("loginDate", now.getTime())
                .setIssuedAt(now)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secretKey)
                .compact();

        log.info("jwt token 생성완료! = {}", token);

        return token;

    }


}

 

 


# 6. 기존 유저일 시 로그인 통과 후 jwt 토큰 발급

1. 로그인 타입과 이메일 정보를 가지고 회원정보를 조회한다.

2. 조회한 회원데이터가 Null 이라면 회원가입 진행을 존재한다면 로그인 성공 후 로그인 시간을 업데이트 후 토큰을 반환해준다.

 

# 1. 회원정보 조회

Map<String, Object> targetMember = 
memberService.findByEmailAndLoginType(userInfo.getKakaoAccount().getEmail(), LoginType.kakao.name());

 

카카오 서버에 요청한 사용자 정보 토대로 조회를 한다.

# 2. 회원정보 null 체크

조회한 회원데이터가 Null 이라면 회원가입 진행을 존재한다면 로그인 성공 후 로그인 시간을 업데이트 후 토큰을 반환해준다.

   MemberDto memberDto = MemberDto.builder()
                .userName(userInfo.getProperties().getNickname())
                .socialToken(accessToken)
                .email(userInfo.getKakaoAccount().getEmail())
                .loginType(LoginType.kakao.name())
                .jwtToken(jwtToken)
                .loginTime(LocalDateTime.now())
                .registrationDate(LocalDateTime.now())
                .build();
  
  if (targetMember == null) {
            // 이름, 이메일, 토큰, 로그인 타입, 카카오토큰 DB 저장 로그인 시간 적재 + 메타 데이터 등등 로그인 시간은 무조건 업데이트 해주기
            log.info("create MemberDto = {}", memberDto);
            memberService.insertSocialMember(memberDto);
        }

        memberService.updateLoginTimeByEmail(memberDto);

 

# 3. 토큰과 화면을 그릴 유저정보를 같이 반환해준다.

  Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("userInfo", memberDto);
        resultMap.put("jwtToken", jwtToken);
        
return ResponseEntity.of(Optional.of(resultMap));

# 8. 로그인 요청 외에 토큰 검증하는 인터셉터 기능 구현하여 해당 요청이 권한 확인 

1. 토큰을 검증하는 인터셉터 객체 구현

2. WebMvcConfigurer 구현하여 인터셉터 객체 등록하기

 

# 1. 토큰 검증 인터셉터 구현

* secrectKey는 현재 코드 블럭에서 삭제함 안전한곳에 보관해야함.

1. HandlerInterceptor 구현한 후 preHandle 함수를 재정의

2. jwt token 복호화 하는 코드 작성

3. 복호화 실패 시 사용자단에서 조작한 토큰으로 간주하고 로그인 실패 혹은 토큰 유효시간이 지나면 다시 로그인 화면으로 이동

 

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenValidator implements HandlerInterceptor {

    //TODO 키값 안전한곳으로 보관해야함.

    private final RequestScopeBean requestScopeBean;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("########### jwt 토큰 preHandle #########");
        log.info("SECRET_KEY = {}", SECRET_KEY);

        if ("OPTIONS".equals(request.getMethod())) {
            // OPTIONS 요청이면 true 반환
            return true;
        }

        String jwtToken = request.getHeader("Authorization");
        String pureToken = jwtToken.substring(7);

        log.info("토큰값 체크!", jwtToken);

        if (jwtToken == null || jwtToken.isEmpty()) {
            response.sendRedirect("/api/login");
            return false;
        }

        try {
            byte[] keyBytes = SECRET_KEY.getBytes(StandardCharsets.UTF_8);
            SecretKey key = Keys.hmacShaKeyFor(keyBytes);

            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(pureToken);

            Claims claims = claimsJws.getBody();
            String username = claims.getSubject();
            Date expirationDate = claims.getExpiration();

            Date now = new Date();

            // 유효기간이 지난 시간으로 설정 (현재 시간보다 이전)
            Date expiredDate = new Date(now.getTime() - 3600 * 1000); // 1시간 전

            log.info("check token value = {}", claims);
            log.info("uesrname = {}", username);
            log.info("expireData = {}", expirationDate);

            MemberDto memberDto = MemberDto.builder()
                    .userName(username)
                    .email((String) claims.get("email"))
                    .loginType((String) claims.get("loginType"))
                    .build();

            requestScopeBean.setData(memberDto);

            log.info("요청객체 상태확인 = {}", requestScopeBean.getData());

            if (expirationDate.before(new Date())) {
                log.info("토큰 유효날짜가 지났습니다. 다시 로그인을 요청하도록 하세요");
                response.sendRedirect("/api/login");
                return false;
            } else {
                log.info("통과");
            }

        } catch(Exception e) {
            response.sendRedirect("/api/login");
            throw new TokenException(e.getMessage());
        }

        return true;
    }


}

 

# 2. WebMvcConfigurer 구현하여 인터셉터 객체 등록하기

1. WebConfig 객체를 생성 WebMvcConfigurer implement

2. addInterceptors 재정의

3. registry 객체에서 addInterceptor 함수사용하여 위 작성한 객체 입력

 

  registry.addInterceptor(new JwtTokenValidator(requestScopeBean))
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/api/kakaoLogin");

 

  • order : 작동될 인터셉터 순서 (로그 인터셉터를 1로 두었기 때문에 토큰 검증인터셉터는 2번 순으로 넣음)
  • addpathpatterns (어느 요청 uri 패턴을 허용할 것인지 /** 로 모든 요청을 들어가게 설정)
  • excludePathPatterns (제외하고 싶은 특정 uri를 입력하면 해당 요청 uri는 토큰 검증을 하지 않음 현재 나는 로그인 시에는 토큰이 존재하지 않기 때문에 로그인 요청 uri를 추가하였음)