Programming-[Backend]/Keycloak

Keycloak Device flow

컴퓨터 탐험가 찰리 2024. 9. 17. 13:53
728x90
반응형

 

1. Device flow 개념

Ref.) https://blog.please-open.it/device_code/

 

 

Device flow는 스마트 TV 등 사용자가 인증 정보를 직접 입력하기 어려운 환경에서 스마트폰 등 입력이 편한 환경에서 로그인 할 수 있도록 인증 기기를 대체하는 방식이다. 넷플릭스 등에서 인증 시, QR 코드를 스캔하여 핸드폰으로 로그인하는 방식이라고 이해하면 된다.

 

 

 

 

2. 백엔드 구현

 

  1. Keycloak 설정: Client - 특정 client 선택 - Capability Config - OAuth 2.0 Device Authorization Grant를 활성화한다.
  2. device flow를 위한 Endpoint로 다음과 같이 로그인 요청을 보낸다.
curl --location --request POST 'https://app.please-open.it/auth/realms/--realmid--/protocol/openid-connect/auth/device' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=--client_id--' \
--data-urlencode 'client_secret=--client_secret--'

 

 

3. 다음과 같은 응답이 온다. verification_uri로 접속하면 user_code를 입력해야하고, verification_uri_complete로 접속 시 user_code가 자동 입력되어 사용자 로그인 페이지로 이동한다. verification_uri_complete를 QR Code로 만들고 다른 Device로 촬영하면 사용자가 다른 Device에서 로그인할 수 있으므로 Device flow이다.

{
    "device_code": "0tNVhQxNbgA-F3s344YYZBsw0njBgZK_zPBaVzKvAFs",
    "user_code": "TQMZ-OSNL",
    "verification_uri": "http://localhost:8080/realms/{realmId}/device",
    "verification_uri_complete": "http://localhost:8080/realms/{realmId}/device?user_code=TQMZ-OSNL",
    "expires_in": 600,
    "interval": 5
}

 

4. 클라이언트는 다음 url을 polling하고 있어야한다. 실제 유저가 로그인 전까지는 pending 응답이 오고, 유저 로그인 후에는 access_token등 토큰 정보가 아래와 같이 넘어온다.

curl --location --request POST 'https://app.please-open.it/auth/realms/--realmid--/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'device_code=--device_code--' \
--data-urlencode 'client_id=--client_id--' \
--data-urlencode 'client_secret=--client_secret--' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' 
  • 응답: pending 중일 때
{
    "error": "authorization_pending",
    "error_description": "The authorization request is still pending"
}
  • 응답: 유저가 로그인 한 이후
{
    "access_token": "eyJhbG...",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbG...",
    "token_type": "Bearer",
    "not-before-policy": {TIMESTAMP 값},
    "session_state": "{UUID로 된 state값}",
    "scope": "email profile"
}

 

 

3. Frontend 구현

 

로그인 버튼을 눌렀을 때 팝업창을 띄우고, 백엔드에서 받아오는 verification_complete_uri 값을 QR로 만든다. 핸드폰으로 QR 촬영 후 사용자가 로그인을 처리하면 로그인이 되도록 polling을 한다. 

 

React로 만들었고, handleLoginClick 코드 부분만 대략적으로만 기록해놓는다.

 

 

const handleLoginClick = async () => {
  popupRef.current = window.open(
      'about:blank',
    '_blank', // 새 창에서 열기
    'width=500,height=700'
  );

  try {
    // 첫 번째 POST 요청
    const response = await fetch('{백엔드 endpoint 주소}', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ client_id: '클라이언트 id' }),
    });

    const data = await response.json();

    const { verification_uri_complete, device_code, expires_in, interval } = data;

    // QR 코드 생성
    const qrCodeUrl = await QRCode.toDataURL(verification_uri_complete);

    popupRef.current.document.body.innerHTML = `
      <h2>QR 코드를 스캔하여 로그인을 진행해주세요</h2>
      <img src="${qrCodeUrl}" alt="QR Code" />
      <p>${expires_in} 초 내에 로그인 해주세요</p>
    `;

    popupRef.current.document.getElementById("deviceLoginButton").onclick = handleDeviceLoginClick;


    // 두 번째 요청을 위한 polling 시작
    const pollLogin = async () => {
      let timeElapsed = 0;

      while (timeElapsed < expires_in) {
        await new Promise((resolve) => setTimeout(resolve, interval * 1000));

        const pollResponse = await fetch('{백엔드 토큰 포인트 주소}', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ client_id: '{클라이언트 id}', grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }),
        });

        // const pollData = await pollResponse.json();
        const responseText = await pollResponse.text();

        // 데이터가 JSON 형식으로 제대로 수신되지 않는 경우에 대비
        let accessToken = null;
        let refreshToken = null;


        try {
          // JSON 파싱 시도
          const pollData = JSON.parse(responseText);
          // access_token과 refresh_token 추출
          accessToken = pollData.access_token;
          refreshToken = pollData.refresh_token;

          if (pollData.error) {
            popupRef.current.document.body.innerHTML += `<p>사용자의 로그인을 기다리고 있습니다.</p>`;
          } else if (pollData.access_token && pollData.refresh_token) {
            saveTokens(pollData.access_token, pollData.refresh_token);
            popupRef.current.document.body.innerHTML = `<p>로그인 성공</p>`;
            setTimeout(() => popupRef.current.close(), 3000);
            return;
          }

        } catch (e) {
          // 파싱 오류 발생 시 텍스트 내에서 직접 추출 시도
          console.error("JSON 파싱 오류:", e);

          // access_token 추출
          const accessTokenMatch = responseText.match(/"access_token":"(.*?)"/);
          if (accessTokenMatch) {
            accessToken = accessTokenMatch[1];
          }

          // refresh_token 추출
          const refreshTokenMatch = responseText.match(/"refresh_token":"(.*?)"/);
          if (refreshTokenMatch) {
            refreshToken = refreshTokenMatch[1];
          }

          saveTokens(accessToken, refreshToken);
          popupRef.current.document.body.innerHTML = `<p>로그인 성공</p>`;
          setTimeout(() => popupRef.current.close(), 3000);
          return;
        }

        timeElapsed += interval;
      }

      popupRef.current.document.body.innerHTML += `<p>시간이 만료되었습니다</p>`;
    };

    await pollLogin();
  } catch (error) {
    console.error('로그인 중 오류:', error);
    if (popupRef.current) {
      popupRef.current.document.body.innerHTML = `<p>오류 발생: ${error.message}</p>`;
    }
  }
};

 

 

추가로, 자체 테스트를 진행할 경우, 내 컴퓨터에서 인식하는 localhost와 모바일에서 인식하는 localhost가 다르기 때문에, 백엔드 서버의 주소 및 Keycloak의 Frontend URL 값을 '터미널 -> ifconfig -> en0' 에 해당하는 ip 주소값과 포트 번호로 변경해주어 테스트해야한다.

 

대략 이런 화면을 만들어서 테스트했다.

 

 

728x90
반응형