iT邦幫忙

2021 iThome 鐵人賽

1
Software Development

用Keycloak學習身份驗證與授權系列 第 36

Day33 - 【實戰篇-預告】Device Code(4)

本系列文之後也會置於個人網站


本篇應一些後續考量移除了部分內容。若對本篇有興趣還請多關注本系列後續消息。
(不過有可能會改用Electron.js改寫,雖然架構上會再麻煩一些。在此前也可以留言讓我知道想看PySide完整版本還是Electron.js完整版本)

這次應用使用PySide來實現界面;qrcode來產生需要的QR Code;並使用requests來與身份驗證與授權伺服器的API溝通。現在透過pip進行安裝需要的packages。

pip install PySide6 requests qrcode

其實本來可以考慮用electron.js,但是基於一些考量,最後決定使用PySide。

在昨天,透過Qt Designer建立了兩個需要的使用者界面,今天來實現邏輯部分。

建立Widget

在之前所設計的ui檔案分別是:example-device-code-app.uilogin-dialog.ui。這部分會分別將這兩份載入到類別內使用。所以同樣來建立兩個Widgets:ExampleDeviceCodeApploginDialog

class ExampleDeviceCodeApp(QWidget):
    def __init__(self):
        QWidget.__init__(self)
        self.__load_ui()

    def __load_ui(self):
        #......
        pass

class loginDialog(QWidget):
    def __init__(self, api, afterLogin=lambda device_code: None):
        LOG.debug('init')
        QWidget.__init__(self)
        self.__afterLogin = afterLogin
        self.__api = api
        self.__load_ui()

    def __load_ui(self):
        #......
        pass

綁定操作事件

接著需要在ExampleDeviceCodeApp,針對登入按鈕綁定事件。

    def __bind_event(self):
        loginButton: QPushButton = self.findChild(QPushButton, 'loginButton')
        loginButton.clicked.connect(self.onClickLoginBtn)

    def onClickLoginBtn(self):
        self.dialog = loginDialog(api=api,
                                  afterLogin=self.__afterLogin)
        self.dialog.show()

登入按鈕處理的邏輯很簡單,也就是再開一個dialog,也就是loginDialog而已。特別的是,在建立loginDialog時,傳入一個callback,當登入成功的時候出發。

註: 這個寫法並不是Qt常態的Dialog寫法。

    def __afterLogin(self, tokens
                     ):

        user_info = api.getUserInfo(tokens.get('access_token'))
        name = user_info.get('name', "Unknow")
        self.message.setText(f'Hello, {name}!')

關於如何實現API溝通會放到之後說明。登入成成功後會取得access_token,會需要透過這個存取權杖來取得登入的使用者資訊。接著將取得的帳號名稱顯式在畫面上。

與授權伺服器通訊

會需要在建立一個類別來處理API相關的處理。

api類別的基本訊息

class Keycloak:
    '''
    example:
    > api = Keycloak('http://localhost:8080', 'quick-start')
    '''
    def __init__(self, base: str, realm: str, *,
                 client_id: str = None,
                 client_secret: str = None):
        self.__base = base
        self.__realm = realm
        self.client_id = client_id
        self.client_secret = client_secret

    @property
    def base_url(self):
        base = self.__base
        realm = self.__realm
        return f'{base}/auth/realms/{realm}'

這個類別會記憶base_url,要登入的realm。並且在Device Code Flow下還需要在設定Client IdClient Secret

取得device_code的方法

提供一個方式,透過requests.post處理device code的endpint來取得device_codeuser_code

    def getDeviceCode(self, *,
                      client_id=None,
                      client_secret=None):
        #......
        pass

在之前看過Keycloak回傳的資訊可能長成

{
    "device_code": "boTQ6vd49RXTOYOb7dwXBCpHYskzOjXvDPjkXxniMN0",
    "user_code": "HZYO-ROXJ",
    "verification_uri": "http://localhost:8080/auth/realms/quick-start/device",
    "verification_uri_complete": "http://localhost:8080/auth/realms/quick-start/device?user_code=HZYO-ROXJ",
    "expires_in": 600,
    "interval": 5
}

驗證device_code的方法

同樣透過requests來檢查是否有人登入授權了。如果回傳200表示有人登入授權,並可以取得access_tokenrefresh_token

    def vertifyDeviceCode(self,
                          device_code: str,
                          *,
                          client_id=None,
                          client_secret=None):
        #......
        pass

取得使用者資訊的方法

也同樣透過requests呼叫相對應API的endpoint來取得使用者資訊。

    def getUserInfo(self, access_token: str):
        #......
        pass

建立api實例

最後建立一個實例供Widgets使用。

KEYCLOAK_URL = 'http://localhost:8080'
KEYCLOAK_REALM = 'quick-start'
KEYCLOAK_CLIENT_ID = 'example-device-app'
KEYCLOAK_CLIENT_SECRET = '2eefb27e-ac98-47c4-8ac5-82e8edc73b30'

api = Keycloak(KEYCLOAK_URL, KEYCLOAK_REALM,
               client_id=KEYCLOAK_CLIENT_ID,
               client_secret=KEYCLOAK_CLIENT_SECRET)

處理Device Code檢查邏輯

再回到loginDialog類別,在__init__在添加個self.__init_api(),好讓在畫面啓動後自動取取得一個新的device_code,並等待使用者登入。

取得device_code

__init_api的內容,首先會需要取得device_code的相關訊息:

        device_code_info = self.__api.getDeviceCode()
        device_code = device_code_info.get('device_code')
        user_code = device_code_info.get('user_code')
        verification_uri = device_code_info.get('verification_uri', '')
        verification_uri_complete = device_code_info.get('verification_uri_complete', '')
        interval = device_code_info.get('interval', 5)
        expires_in = device_code_info.get('expires_in', 60)

        self.__device_code = device_code
        self.__interval = interval
        self.__expires_in = expires_in
        self.__curr = 0

以Keycloak回傳的資料而言,通常還包含intervalexpires_in。前者最短每隔5秒檢查一次是否有人登入;後者表明這個device_code在多久後過期,無法在使用,也就意味這沒有人登入,需要重新取得device_code,但這裏處理並不會直接重新取得device_code,而是直接關閉dialog。

        if self.__curr > self.__expires_in:
            self.close()
            # self.__timer.stop()
            e = Exception("Timeout: Login Fail.")
            self.__afterLogin(e)
            raise e

產生QR Code

透過qrcode來產生一個包含登入URL資訊的QR Code,並將圖檔資訊儲存於buf

        self.__qrcode = qrcode.make(verification_uri_complete, box_size=250)
        img = self.__qrcode.get_image()
        #......

將資訊顯式在畫面上

最後將相關資訊顯式在畫面上

        base_url_widget: QLabel = self.findChild(QLabel, 'BaseLoginURL')
        base_url_widget.setText(f'[{verification_uri}]({verification_uri})')

        user_code_widget: QLabel = self.findChild(QLabel, 'user_code')
        user_code_widget.setText(f'{user_code}')

當然還包含QR Code (略過)

(keypoint)檢查是否有人登入授權

每隔interval秒需要去檢查一次是否有人登入。使用QTimer來處理,這裏個結果會每5秒去觸發一次self.checkLogin

        self.__timer = QTimer()
        timer: QTimer = self.__timer
        timer.timeout.connect(self.checkLogin)
        timer.start(interval*1000)

self.checkLogin的內容主要也就是透過API去檢查是否有人登入授權:

            result = self.__api.vertifyDeviceCode(self.__device_code)

如果登入成功的話,就在呼叫__afterLogin。將資訊返回給主要的Widget。

        self.__afterLogin(result)
        self.close()

取得使用者訊息,並顯式在畫面上

回到ExampleDeviceCodeApp。在登入以後,取得access_token。在透過存取權杖取得使用者資訊顯式在畫面上:

    def __afterLogin(self, tokens):
        user_info = api.getUserInfo(tokens.get('access_token'))
        name = user_info.get('name', "Unknow")
        self.message.setText(f'Hello, {name}!')

結語

本系列暫有後續計劃,故本篇隱藏了部分內容。若對全文感興趣還請留意本系列後續發展。
但也保留了關鍵的檢查登入的部分,也依然能夠看出爲何應用知道有人登入授權,看似自己登入一樣。

相似登入的應用在之前也聊過了。這次比較特別的是QTimer那一段吧!這次很清楚知道每5秒會去檢查一次是否有人登入授權。也就是在登入授權以後,最多可能等待個五秒才會更新畫面。

實際上輪詢(polling)的方式可能不是唯一一種檢查方法。甚至這種方式某些程度上感覺有點笨。如果授權伺服器和登入的應用有很高度的親密性的話,獲取可以在登入後,由驗證授權伺服器進一步通知應用去取得存取權杖。不過要這樣處理,不是應用還需要提供一個API端點接受通知,就是需要與驗證授權伺服器建立一個長連線。相對來說輪詢方式真的簡單很多。

參考資料


上一篇
Day32 - 【實戰篇】Device Code(3)
下一篇
Day34 - 【實戰篇-預告】使用iFrame實現Dialog彈跳登入
系列文
用Keycloak學習身份驗證與授權40

尚未有邦友留言

立即登入留言