관리 메뉴

거니의 velog

(41) 리액트 소셜 로그인 4 본문

SpringBoot_React 풀스택 프로젝트

(41) 리액트 소셜 로그인 4

Unlimited00 2024. 3. 7. 20:54

5. 자동 회원 추가 및 회원정보의 반환

* API 서버가 Access Token을 처리해서 사용자의 이메일 정보를 추출하는 것을 확인했다면 이를 이용해서 데이터베이스에 존재하는 회원인지 처음으로 접근한 회원인지에 따라서 데이터베이스에서 조회 혹은 추가(회원가입)를 해 주어야 한다.


(1) MemberService 회원 처리

* 회원정보는 MemberDTO 타입을 통해서 처리되어야 하므로 Member 엔티티 객체를 MemberDTO 객체로 변환하는 entityToDTO() 를 추가한다.

package com.unlimited.mallapi.service;

import java.util.stream.Collectors;

import org.springframework.transaction.annotation.Transactional;

import com.unlimited.mallapi.domain.Member;
import com.unlimited.mallapi.dto.MemberDTO;

// 트랜잭션 처리 어노테이션, rollbackFor 속성을 사용하여 모든 예외에 대해 롤백 처리
@Transactional(rollbackFor = Exception.class)
public interface MemberService {

    // Kakao의 AccessToken을 이용해 회원 정보를 가져오는 메서드
    MemberDTO getKakaoMember(String accessToken);

    // Entity(Member)를 DTO(MemberDTO)로 변환하는 디폴트 메서드
    default MemberDTO entityToDTO(Member member) {
        // Member 엔티티에서 MemberDTO로 필요한 정보를 복사하여 변환
        MemberDTO dto = new MemberDTO(
                member.getEmail(),
                member.getPw(),
                member.getNickname(),
                member.isSocial(),
                // Member 엔티티에 속한 MemberRole 열거형을 문자열 리스트로 변환
                member.getMemberRoleList().stream().map(memberRole -> memberRole.name()).collect(Collectors.toList()));

        return dto; // 변환된 DTO 반환
    }

}

* MemberServiceImpl에서는 MemberRepository와 PasswordEndoder를 주입해서 이메일로 회원을 조회하거나 추가한다(org.springframework.security.crypto.password.PasswordEncoder 추가).

@Service
@RequiredArgsConstructor
@Log4j2
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

* 만일 해당 이메일을 가진 회원이 없다면 새로운 회원을 추가할 때 패스워드를 임의로 생성한다. 이렇게 생성된 패스워드는 PasswordEncoder를 통해서 알아볼 수 없게 되므로 관리자와 사용자 모두 패스워드를 알 수 없게 된다(단방향 암호화).

단방향 암호화(One-Way Hashing)은 데이터를 암호화할 때, 암호화된 결과를 다시 복호화할 수 없는 방식으로 수행되는 암호화 기법입니다. 
즉, 원본 데이터를 암호화하여 해시값(일련의 고정된 길이의 문자열)을 생성하고, 이 해시값을 저장합니다. 
그런 다음 나중에 입력된 데이터도 동일한 알고리즘을 사용하여 해시값을 생성하고, 저장된 해시값과 비교함으로써 데이터 일치 여부를 확인합니다.

다음은 단방향 암호화의 특징과 사용되는 상황에 대한 설명입니다:

1. 복호화 불가능: 단방향 암호화는 암호화된 값을 다시 원본 데이터로 되돌릴 수 없습니다. 이는 주로 보안 강화를 위해 사용되며, 원본 데이터의 복원이 불가능하도록 보장합니다.

2. 고정된 출력 길이: 어떤 크기의 입력이든 간에, 해시 함수의 출력 길이는 고정되어 있습니다. 이는 일반적으로 알고리즘에 따라 정해진 길이의 해시값이 생성되는 특징을 나타냅니다.

3. 충돌 가능성: 서로 다른 입력이 같은 해시값을 생성하는 경우를 충돌이라고 합니다. 강력한 단방향 해시 함수는 충돌이 발생할 확률을 매우 낮게 유지하려고 노력합니다. 충돌이 발생하면, 보안성이 약화될 수 있습니다.

4. 주로 비밀번호 저장에 사용: 단방향 암호화는 주로 사용자의 비밀번호를 저장할 때 적용됩니다. 사용자가 입력한 비밀번호를 저장하는 대신, 해당 비밀번호의 해시값을 저장함으로써 보안을 강화할 수 있습니다.

5. 대표적인 알고리즘: 대표적인 단방향 암호화 알고리즘으로는 MD5, SHA-1, SHA-256, SHA-3 등이 있습니다. 그러나 MD5와 SHA-1은 현재 보안상의 이유로 권장되지 않습니다. 보다 안전한 해시 함수인 SHA-256, SHA-3 등을 사용하는 것이 좋습니다.

예시로 사용자 비밀번호를 저장하는 상황에서, 원본 비밀번호를 저장하지 않고 그에 대한 해시값을 저장함으로써, 시스템이 사용자의 비밀번호를 알지 못하더라도 로그인 등의 인증을 수행할 수 있도록 합니다.

* 패스워드를 모르기 때문에 일반 로그인은 불가능하게 된다. 대신 카카오 로그인 후에는 회원정보를 수정할 수 있도록 구성해서 사용자가 원하는 패스워드를 지정하여 변경할 수 있도록 유도해야 한다.

    /**
     * 임시 비밀번호를 생성하는 메서드입니다.
     *
     * @return 생성된 임시 비밀번호
     */
    private String makeTempPassword() {
        // StringBuffer 객체를 생성하여 임시 비밀번호를 저장할 준비를 합니다.
        StringBuffer buffer = new StringBuffer();

        // 10자리의 임시 비밀번호를 생성하기 위한 반복문입니다.
        for (int i = 0; i < 10; i++) {
            // Math.random()을 이용하여 0 이상 55 미만의 난수를 생성하고, 65를 더하여 ASCII 코드값을 얻습니다.
            // 이를 char로 캐스팅하여 알파벳 대문자(A-Z) 또는 기호를 생성합니다.
            buffer.append((char) ((int) (Math.random() * 55) + 65));
        }

        // 생성된 임시 비밀번호를 문자열로 변환하여 반환합니다.
        return buffer.toString();
    }

* 이메일이 존재하지 않는다면 새로운 com.unlimited.mallapi.domain.Member 객체를 생성해야 하는데 이를 위한 makeSocialMember()를 추가한다(import 필요).

/**
 * 소셜 회원을 생성하는 메서드입니다.
 *
 * @param email 생성할 소셜 회원의 이메일
 * @return 생성된 소셜 회원 객체
 */
private Member makeSocialMember(String email) {
    // 임시 비밀번호를 생성하는 makeTempPassword 메서드를 호출합니다.
    String tempPassword = makeTempPassword();

    // 생성된 임시 비밀번호를 로그에 기록합니다.
    log.info("tempPassword: " + tempPassword);

    // 소셜 회원의 기본 닉네임을 설정합니다.
    String nickname = "소셜회원";

    // Member 객체를 Builder 패턴을 사용하여 생성합니다.
    Member member = Member.builder()
            .email(email) // 이메일 설정
            .pw(passwordEncoder.encode(tempPassword)) // 임시 비밀번호를 암호화하여 저장
            .nickname(nickname) // 닉네임 설정
            .social(true) // 소셜 회원 여부 설정
            .build();

    // 회원 역할(Role)을 추가합니다. 여기서는 일반 사용자 역할을 추가했습니다.
    member.addRole(MemberRole.USER);

    // 생성된 소셜 회원 객체를 반환합니다.
    return member;
}

* 추가된 메서드를 이용해서 getKakaoMember()를 아래와 같이 구성한다.

    /**
     * Kakao Access Token을 이용하여 Kakao 사용자 정보를 가져오는 메서드입니다.
     *
     * @param accessToken Kakao API에 접근하기 위한 Access Token
     * @return Kakao 사용자 정보에 대한 DTO(데이터 전송 객체) 객체
     */
    @Override
    public MemberDTO getKakaoMember(String accessToken) {
        // Kakao API를 통해 사용자 이메일 정보를 얻어옵니다.
        String email = getEmailFromKakaoAccessToken(accessToken);

        // 얻어온 Kakao 사용자 이메일을 로그에 기록합니다.
        log.info("Kakao 사용자 이메일: " + email);

        // 사용자 이메일로 회원 정보를 데이터베이스에서 조회합니다.
        Optional<Member> result = memberRepository.findById(email);

        // 조회된 회원 정보가 존재하는 경우, 해당 회원의 정보를 DTO로 변환하여 반환합니다.
        if (result.isPresent()) {
            MemberDTO memberDTO = entityToDTO(result.get());
            return memberDTO;
        }

        // 조회된 회원 정보가 없는 경우, 새로운 소셜 회원을 생성하고 데이터베이스에 저장합니다.
        Member socialMember = makeSocialMember(email);
        memberRepository.save(socialMember);

        // 생성된 소셜 회원의 정보를 DTO로 변환하여 반환합니다.
        MemberDTO memberDTO = entityToDTO(socialMember);

        return memberDTO;
    }

(2) 컨트롤러의 결과 처리

* MemberService 쪽에서 카카오 로그인 사용자에 대한 처리 결과로 반환되는 MemberDTO는 SocialController에서 일반 로그인과 동일하게 JSON 데이터가 될 수 있도록 처리한다. 이 때, JWTUtil을 이용해서 API 서버 접근 시에 사용할 Access Token과 Refresh Token을 발행해서 추가한다.

package com.unlimited.mallapi.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.unlimited.mallapi.dto.MemberDTO;
import com.unlimited.mallapi.service.MemberService;
import com.unlimited.mallapi.util.JWTUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@Log4j2
@RequiredArgsConstructor
public class SocialController {

    private final MemberService memberService;

    /**
     * Kakao Access Token을 이용하여 Kakao 사용자 정보를 조회하는 API 엔드포인트입니다.
     *
     * @param accessToken Kakao API에 접근하기 위한 Access Token
     * @return 사용자 정보를 담은 맵 또는 메시지 (현재는 더미 데이터 반환)
     */
    @GetMapping("/api/member/kakao")
    public Map<String, Object> getMemberFromKakao(@RequestParam("accessToken") String accessToken) {
        // 엔드포인트 호출을 로그에 기록합니다.
        log.info("Kakao 사용자 정보 조회를 위한 엔드포인트 호출");
        // 전달받은 Access Token을 로그에 기록합니다.
        log.info("전달받은 Access Token: " + accessToken);

        // Kakao 사용자 정보를 조회하고, 해당 정보를 MemberDTO로 받아옵니다.
        MemberDTO memberDTO = memberService.getKakaoMember(accessToken);

        // MemberDTO에서 사용자 정보를 담은 맵(claims)을 추출합니다.
        Map<String, Object> claims = memberDTO.getClaims();

        // JWTUtil을 사용하여 Access Token 및 Refresh Token을 생성합니다.
        String jwtAccessToken = JWTUtil.generateToken(claims, 10); // 10분 동안 유효한 Access Token
        String jwtRefreshToken = JWTUtil.generateToken(claims, 60 * 24); // 24시간 동안 유효한 Refresh Token

        // 생성된 토큰들을 맵에 추가합니다.
        claims.put("accessToken", jwtAccessToken);
        claims.put("refreshToken", jwtRefreshToken);

        // 최종적으로 사용자 정보 및 토큰들을 반환합니다.
        return claims;
    }

}

* 브라우저에서 결과를 확인하면 로그인 결과는 동일한 화면이지만, API 서버와 리액트 양쪽 모두 많은 변화가 있게 된다. 우선 카카오 로그인을 브라우저에서 실행한다.

* API 서버의 로그를 확인하면 아래와 같이 임의의 패스워드가 생성된 것을 확인할 수 있고, 데이터베이스에 해당 이메일이 없다면 insert문이 처리되는 것을 확인할 수 있다.

* 브라우저의 KakaoRedirectPage에서는 API 서버에서 반환한 사용자 정보를 콘솔창에서 확인할 수 있다.


(3) 리액트의 로그인 처리

* API 서버에서 보내준 데이터는 기존의 로그인 데이터의 구성과 동일하기 때문에 이를 이용해서 로그인 처리를 한다. 로그인 처리는 loginSlice의 login()을 사용할 수 있다.

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { loginPost } from "../api/memberApi";
import { setCookie, getCookie, removeCookie } from "../util/cookieUtil";

(...)

const loginSlice = createSlice({
  name: "LoginSlice",
  initialState: loadMemberCookie() || initState, // 쿠키가 없다면 초깃값사용
  reducers: {
    login: (state, action) => {
      console.log("login.....");

      // 소셜로그인 회원이 사용
      const payload = action.payload;

      setCookie("member", JSON.stringify(payload), 1); //1일
      return payload;
    },
    logout: (state, action) => {
      console.log("logout....");

      removeCookie("member");
      return { ...initState };
    },
  },
  extraReducers: (builder) => {
    (...)
  },
});

export const { login, logout } = loginSlice.actions;

export default loginSlice.reducer;

* KakaoRedirectPage에서는 API 서버에서 전송한 결과를 dispatch()를 이용해서 login()을 호출한다.

import React, { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { getAccessToken, getMemberWithAccessToken } from "../../api/kakaoApi";
import { useDispatch } from "react-redux";
import { login } from "../../slices/loginSlice";

const KakaoRedirectPage = () => {
  const [searchParams] = useSearchParams();
  const authCode = searchParams.get("code");
  const dispatch = useDispatch();

  useEffect(() => {
    getAccessToken(authCode).then((accessToken) => {
      console.log(accessToken);

      getMemberWithAccessToken(accessToken).then((memberInfo) => {
        console.log("-------------------");
        console.log(memberInfo);
        dispatch(login(memberInfo));
      });
    });
  }, [authCode, dispatch]);

  return (
    <div>
      <div>Kakao Login Redirect</div>
      <div>{authCode}</div>
    </div>
  );
};

export default KakaoRedirectPage;

* 화면에서 카카오 로그인을 실행하면 API 서버에서 전송된 결과로 쿠키가 생성되는 것을 확인할 수 있다.


(4) 화면 이동 처리

* 로그인에 대한 쿠키 처리가 완료되었다면 마지막으로 화면을 이동시켜 주어야 한다. 이 때 카카오 로그인 사용자라면 회원정보를 수정하도록 이동해서 임시로 발행된 패스워드가 아니라 자신이 직접 패스워드나 닉네임을 지정할 수 있어야 한다.

* 만일 소셜 로그인한 사용자가 일반 회원이었다면 기존과 동일하게 / 경로로 이동할 수 있도록 처리한다. 이동에 대한 처리는 기존에 만들어둔 useCustomLogin을 재사용한다.

import React, { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { getAccessToken, getMemberWithAccessToken } from "../../api/kakaoApi";
import { useDispatch } from "react-redux";
import { login } from "../../slices/loginSlice";
import useCustomLogin from "../../hooks/useCustomLogin";

const KakaoRedirectPage = () => {
  const [searchParams] = useSearchParams();
  const authCode = searchParams.get("code");
  const dispatch = useDispatch();
  const { moveToPath } = useCustomLogin();

  useEffect(() => {
    getAccessToken(authCode).then((accessToken) => {
      console.log(accessToken);

      getMemberWithAccessToken(accessToken).then((memberInfo) => {
        console.log("-------------------");
        console.log(memberInfo);
        dispatch(login(memberInfo));
        // 소셜 회원이 아니라면
        if (memberInfo && !memberInfo.social) {
          moveToPath("/");
        } else {
          moveToPath("/member/modify");
        }
      });
    });
  }, [authCode, dispatch]); // moveToPath 또한 의존성 배열에서 무한 루프이므로 제외

  return (
    <div>
      <div>Kakao Login Redirect</div>
      <div>{authCode}</div>
    </div>
  );
};

export default KakaoRedirectPage;

* 변경된 코드는 현재 데이터베이스에 없는 사용자가 카카오 로그인을 한 경우 /member/modify 로 이동하게 되는데 아직은 해당 페이지가 없기 때문에 에러 화면(404 Not Found)만 보게 된다.