Just Do IT!

[spring+react] OAuth2 로그인 구현 (구글, 카카오) 본문

프로젝트

[spring+react] OAuth2 로그인 구현 (구글, 카카오)

MOON달 2024. 9. 20. 12:41
728x90

OAuth2 로그인 과정 (예시에는 google이지만 다른 소셜 서비스도 가능)

 

구글 로그인

google cloud에서 앱 생성하기

OAuth 동의 화면에서 앱 등록

범위 설정

OAuth Client ID 만들기

 

서버에 올리지 않았기에 localhost:3000을 추가해주었다.

 

 

 

카카오 로그인

kakao develop에 앱 추가

redirect URI 추가

REST API key, 보안 키 설정

 

 

 

 

 

위의 과정을 거치고 나면, 해당 프로젝트에 필요한 것들을 모두 세팅할 수 있게 된다.

그리고 이제 코드로 구현하면 된다.

 

 

 

 

 

 

 

 

Java 코드 구현 [백엔드]

User entity 수정

public class User implements UserDetails {

	@Column(nullable = true)
	private String password;

	@Column
	@Builder.Default
	private boolean oAuth = false;

}

 

다른 부분은 생략하고, 바뀐 부분만 적어두었다.

이제 password는 필수값이 아니라 선택값이 되었다. 소셜 로그인을 하면 비밀번호가 필요 없기 때문이다.

그리고 중복된 가입을 막기 위해 oAuth인지 아닌지 boolean으로 저장하도록 column을 추가했다.

application.yml 파일에 oauth2 관련 코드 추가

oauth2:
  clients:
    google:
      client-id:
      client-secret: 
      redirect-uri: http://localhost:3000/oauth/google
      token-uri: https://oauth2.googleapis.com/token
      user-info-request-uri: https://www.googleapis.com/oauth2/v3/userinfo
    kakao:
      client-id: 
      client-secret: 
      redirect-uri: http://localhost:3000/oauth/kakao
      token-uri: https://kauth.kakao.com/oauth/token
      user-info-request-uri: https://kapi.kakao.com/v2/user/me

 

url을 하나하나 하드코딩 하지 않기 위해 yml 파일에 관련 코드를 추가해준다.

client id와 client secret은 보안 키이기 때문에 꼭 메모장에 복사해두거나 백업해두고 써야 한다.

그리고 이 보안 키는 노출되지 않도록 주의해야 한다.

 

OAuth2Properties.java

@Data
@Component
@ConfigurationProperties(prefix = "oauth2")
public class OAuth2Properties {
	private Map<String, Client> clients;

	@Data
	public static class Client {
		private String clientId;
		private String clientSecret;
		private String redirectUri;
		private String tokenUri;
		private String userInfoRequestUri;
	}
}

 

yml 파일에 적어놓은 요소들을 사용하기 위해 utils 패키지 안에 클래스를 추가해주었다.

 

OAuthController 추가 

@RestController
@Slf4j
@RequestMapping("/api/oauth")
@RequiredArgsConstructor
public class OAuthController {
	private final OAuthService oAuthService;

	@GetMapping("/{provider}")
	public ResponseEntity<?> oAuthSignIn(@RequestParam("code") final String code,
			@PathVariable("provider") final String provider, HttpServletResponse response) {
		log.info("provider: {}", provider);

		String accessToken = oAuthService.oAuthSignIn(code, provider, response);

		if (accessToken == null) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
		}

		LoginResponse loginResponse = LoginResponse.builder().accessToken(accessToken).build();
		return ResponseEntity.ok(loginResponse);
	}
}

 

소셜 로그인을 위한 controller를 추가해준다.

여기서 provider가 구글, 카카오 같은 소셜을 의미한다. OAuth 동작 과정은 동일하기 때문에 효율적인 코드를 구현하기 위해서 url을 provider로 받아서 반복되는 코드를 줄여주었다.

 

OAuthService 코드

@Service
@RequiredArgsConstructor
public class OAuthServiceImpl implements OAuthService {
	private final OAuth2Properties oAuth2Properties;
	private final UserRepository userRepository;
	private final TokenUtils tokenUtils;

	@Override
	public String oAuthSignIn(String code, String provider, HttpServletResponse res) {
		// 1. code를 통해 provider에서 제공하는 accessToken 가져온다.
		String providedAccessToken = getAccessToken(code, provider);
		// 2. provider에서 제공하는 accessToken으로 사용자 정보를 추출한다.
		User user = generateOAuthUser(providedAccessToken, provider);
		// 3. 사용자 정보를 조회하고
		// 만약 기존에 있는 사용자라면 (OAUTH 인증 여부에 따라 OAUTH TRUE로 변경)
		// 만약 기존에 없는 사용자라면 (새로 가입_DB 추가)
		user = userRepository.findByEmail(user.getEmail()).orElse(user);
		if (!user.isOAuth()) {
			user.setOAuth(true);
		}

		// 4. 자동 로그인 (사용자에 대한 정보로 accessToken과 refreshToken를 만들어서)
		Map<String, String> tokenMap = tokenUtils.generateToken(user);

		// DB에 기록(refresh)
		user.setRefreshToken(tokenMap.get("refreshToken"));
		userRepository.save(user);
		// HEADER에 추가(refresh)
		tokenUtils.setRefreshTokenCookie(res, tokenMap.get("refreshToken"));
		// BODY에 추가(access)
		return tokenMap.get("accessToken");
	}

	private String getAccessToken(String code, String provider) {
		// 설정 가져오기
		OAuth2Properties.Client client = oAuth2Properties.getClients().get(provider);

		// 1. code를 통해 google에서 제공하는 accessToken 가져온다.
		String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8);

		HttpHeaders headers = new HttpHeaders();
		headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
		headers.setBasicAuth(client.getClientId(), client.getClientSecret());

		MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
		params.add("client_id", client.getClientId());
		params.add("client_secret", client.getClientSecret());
		params.add("code", decodedCode);
		params.add("grant_type", "authorization_code");
		params.add("redirect_uri", client.getRedirectUri());

		RestTemplate rt = new RestTemplate();
		HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
		ResponseEntity<Map> responseEntity = rt.postForEntity(client.getTokenUri(), requestEntity, Map.class);

		if (!responseEntity.getStatusCode().is2xxSuccessful() || responseEntity.getBody() == null) {
			throw new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자 정보를 가져올 수 없음");
		}

		return (String) responseEntity.getBody().get("access_token");
	}

	private User generateOAuthUser(String accessToken, String provider) {
		// 설정 가져오기
		OAuth2Properties.Client client = oAuth2Properties.getClients().get(provider);

		HttpHeaders headers = new HttpHeaders();
		headers.add("Authorization", "Bearer " + accessToken);

		RestTemplate rt = new RestTemplate();
		ResponseEntity<JsonNode> responseEntity = rt.exchange(client.getUserInfoRequestUri(), HttpMethod.GET,
				new HttpEntity<>(headers), JsonNode.class);

		if (!responseEntity.getStatusCode().is2xxSuccessful() || responseEntity.getBody() == null) {
			throw new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자 정보를 가져올 수 없음");
		}

		JsonNode jsonNode = responseEntity.getBody();
		System.out.println(jsonNode);

		String email = null;
		String name = null;
		User user = null;
		try {
			if (jsonNode.has("email") && jsonNode.has("name")) {
				email = jsonNode.get("email").asText();
				name = jsonNode.get("name").asText();
			} else if (jsonNode.has("id") && jsonNode.has("properties")) {
				email = jsonNode.get("id").asText() + "@kakao.com";
				name = jsonNode.get("properties").get("nickname").asText();
			}
			user = User.builder().email(email).name(name).build();
		} catch (RuntimeException e) {
			throw new RuntimeException("해당 사용자를 찾을 수 없습니다.");
		}
		return user;
	}
}

 

코드가 좀 길지만, 흐름을 알면 잘 파악할 수 있다.

맨 첫번째 그림으로 작성해놓은 OAuth의 로그인 동작을 참고하면 된다.

 

AuthProvider에서 응답받은 AccessToken으로, spring에서 AccessToken을 파싱하고 정보를 추출한다.

그리고 DB에서 사용자를 조회하고 회원가입을 하거나, OAuth 여부를 수정한다.

만약 사용자가 존재한다면 OAuth 여부를 true로 변경하여 저장하고,

사용자가 존재하지 않는다면 (=새로운 회원가입) DB에 새로운 사용자를 추가해주면 된다.

 

그리고 security에서 token을 사용하는 것처럼,

AccessToken과 RefreshToken을 담아 클라이언트에 전달하면 된다.

 

여기서 한 가지 문제점이 있다.

카카오가 이제 더이상 이메일을 제공하지 않는다는 것이다.

실습에서는 이메일을 필수값으로 지정해두어서 임의로 이메일처럼 id값으로 만들었다.

if (jsonNode.has("email") && jsonNode.has("name")) {
				email = jsonNode.get("email").asText();
				name = jsonNode.get("name").asText();
			} else if (jsonNode.has("id") && jsonNode.has("properties")) {
				email = jsonNode.get("id").asText() + "@kakao.com";
				name = jsonNode.get("properties").get("nickname").asText();
			}
			user = User.builder().email(email).name(name).build();

그게 바로 이 부분이다.

만약 이메일이 없다면 id를 합쳐서 이메일처럼 보이도록 만들어두었다.

 

이건 나중에 최종 프로젝트나, 다른 프로젝트를 진행할 때 어떤 식으로 만들지 고민해야 겠다.

그냥 아이디, 비밀번호로 회원가입을 만들고 이메일은 그냥 중복 여부 체크로 만들어야 할지도...?

 

아무튼,

이러한 흐름으로 작성하면 백엔드 코드는 끝!

 

 

 

 

 

 

 

 

 

React 코드 구현 [프론트]

.env 파일 수정

REACT_APP_REST_SERVER=http://localhost:8080/api
REACT_APP_GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google
REACT_APP_GOOGLE_ID=
REACT_APP_KAKAO_REDIRECT_URI=http://localhost:3000/oauth/kakao
REACT_APP_KAKAO_ID=

 

원래는 server만 추가해두었는데, application.yml에 추가한 것처럼 google id와 kakao id를 추가해두었다.

보안을 위해서이고, .env 파일관련은 내가 따로 블로그에 정리했었다. (보러가기)

 

아무튼 이 부분도 추가 완료.

* 참고) env 파일을 수정하면 서버를 껐다가 다시 켜야 한다. 그렇지 않으면 반영되지 않는다.

 

Route 추가

<Route path="/oauth/:provider" element={<OAuthLogin />} />

 

기존에 있던 router가 아니므로 새롭게 OAuth 관련 route를 추가해준다.

 

OAuthAPI 추가

export const oauthAPI = {
  googleLogin: (code) => api.get(`/oauth/google?code=${code}`),
  kakaoLogin: (code) => api.get(`/oauth/kakao?code=${code}`),
};

 

실습에서는 api 관련 코드를 따로 모아 파일을 만들어서 관리해주고 있었다.

그렇기에 OAuth API 관련도 따로 추가해주었다.

 

api 관련 코드는 좀 길어서 블로그 글에서는 생략한다. (어차피 나만 봄)

 

OAuthLogin.jsx 파일 생성

const OAuthLogin = () => {
  const { provider } = useParams();
  const code = new URLSearchParams(window.location.search).get("code");
  const navigate = useNavigate();

  const oAuthAPI = {
    kakao: (code) => oauthAPI.kakaoLogin(code),
    google: (code) => oauthAPI.googleLogin(code),
  };

  useEffect(() => {
    const login = async () => {
      try {
        const response = await oAuthAPI[provider](code);
        if (response.status !== 200) {
          throw new Error("로그인 실패");
        } else {
          setCookie("accessToken", response.data.accessToken, { path: "/" });
          navigate("/products");
        }
      } catch (error) {
        console.error(error);
      }
    };

    if (code) {
      login();
    }
  }, [code]);

  return (
    <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-800">
      <div className="flex flex-col items-center">
        <div className="loader mb-4">
          <svg
            className="animate-spin h-10 w-10 text-cyan-600"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 118 8v-4a4 4 0 10-4-4H4z"
            />
          </svg>
        </div>
        <p className="text-lg text-gray-700 dark:text-gray-300">
          로그인 중입니다. 잠시만 기다려 주세요...
        </p>
      </div>
    </div>
  );
};

export default OAuthLogin;

 

OAuth 로그인이 동작하는 부분 코드를 추가해주었다.

return 부분은 로그인이 동작하면서 loading 중인 화면이고 로그인 후 금방 바뀌게 된다.

(코드가 긴 건 내가 loading 화면 스타일을 추가 했기 때문)

 

LoginForm에 소셜 로그인 버튼 추가

  const handleGoogleLogin = () => {
    const params = new URLSearchParams({
      scope: "email profile",
      response_type: "code",
      redirect_uri: process.env.REACT_APP_GOOGLE_REDIRECT_URI,
      client_id: process.env.REACT_APP_GOOGLE_ID,
    });

    const GOOGLE_URL = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;

    window.location.href = GOOGLE_URL;
  };

  const handleKakaoLogin = () => {
    const params = new URLSearchParams({
      response_type: "code",
      redirect_uri: process.env.REACT_APP_KAKAO_REDIRECT_URI,
      client_id: process.env.REACT_APP_KAKAO_ID,
    });

    const KAKAO_URL = `https://kauth.kakao.com/oauth/authorize?${params.toString()}`;

    window.location.href = KAKAO_URL;
  };
  
  ...
  
  return (
      {/* social login */}
      <div className="mt-4 flex flex-col gap-2">
        <Button
          type="button"
          className="flex items-center justify-center bg-white border border-gray-300 rounded-md shadow hover:bg-gray-100"
          onClick={handleGoogleLogin}
          fullWidth
        >
          <div className="flex items-center">
            <img src={google} alt="Google Logo" className="h-5 mr-2" />
            <span className="text-gray-800">구글로 로그인</span>
          </div>
        </Button>
        <Button
          type="button"
          className="flex items-center justify-center bg-yellow-400 text-white rounded-md shadow hover:bg-yellow-500"
          onClick={handleKakaoLogin}
          fullWidth
        >
          <div className="flex items-center">
            <img src={kakao} alt="Kakao Logo" className="h-5 mr-2" />
            <span>카카오로 로그인</span>
          </div>
        </Button>
      </div>
    )


카카오, 구글 버튼을 누르면 동작하는 부분을 추가해주었다.

 

버튼을 눌러 회원가입을 하면, AuthProvider에서 적절한 요청인지 검증하고 Code로 응답하는 과정이다.

Code로 응답 받으면 위의 OAuthLogin 파일의 로그인 과정을 거쳐 로그인을 하게 된다.

 

 

 

 

 

 

이렇게 작성하게 되면 구글 로그인, 카카오 로그인을 할 수 있게 된다.

DB에 저장된 로그인
구글로 로그인한 화면

 

이런식으로 잘 로그인 되는 걸 확인할 수 있다.

 

 

 

 

 

 

 

 

 


내일배움캠프에서 소셜 로그인 때문에 거의 한달 가까이 애를 먹은 적이 있었다.

그 당시에는 백엔드 파트도 없었고 firebase를 사용했어야 해서 더 힘들었지만...

아무튼 소셜 로그인에 대한 한(?)이 있었다.

 

어제 위 과정을 거쳐서 배웠고, 오늘 오전에 실습을 하면서 다시 한번 복습해보니 역시 재밌다(!)

프론트엔드 파트를 담당할 때 로그인, 회원가입을 많이 맡아서 그런지 소셜 로그인에 대한 미련을 버리지 못했었는데,

백엔드 파트도 해보고 프론트도 같이 하니까 흐름을 알게 되는 듯 하다. (자신있지는 않지만)

사실 오전 실습도 어제 했던 걸 보면서 거의 따라치기 수준밖에 되지 않지만....ㅋㅋㅋㅋㅋㅋㅋㅋ

최종 프로젝트에서는 다른 소셜 로그인도 진행해보고 싶다. 그리고 OAuth에 대해 조금 더 공부해봐야지.

늘 실습은 참 재미있다.