本系列文之後也會置於個人網站
本篇應一些後續考量移除了部分內容。若對本篇有興趣還請多關注本系列後續消息。
(不過有可能會改用Electron.js改寫,雖然架構上會再麻煩一些。在此前也可以留言讓我知道想看PySide完整版本還是Electron.js完整版本)
這次應用使用PySide
來實現界面;qrcode
來產生需要的QR Code;並使用requests
來與身份驗證與授權伺服器的API溝通。現在透過pip
進行安裝需要的packages。
pip install PySide6 requests qrcode
其實本來可以考慮用electron.js,但是基於一些考量,最後決定使用PySide。
在昨天,透過Qt Designer建立了兩個需要的使用者界面,今天來實現邏輯部分。
在之前所設計的ui檔案分別是:example-device-code-app.ui
和login-dialog.ui
。這部分會分別將這兩份載入到類別內使用。所以同樣來建立兩個Widgets:ExampleDeviceCodeApp
和loginDialog
。
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相關的處理。
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 Id
和Client Secret
。
device_code
的方法提供一個方式,透過requests.post
處理device code的endpint來取得device_code
會user_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_token
和refresh_token
。
def vertifyDeviceCode(self,
device_code: str,
*,
client_id=None,
client_secret=None):
#......
pass
也同樣透過requests
呼叫相對應API的endpoint來取得使用者資訊。
def getUserInfo(self, access_token: str):
#......
pass
最後建立一個實例供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)
再回到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回傳的資料而言,通常還包含interval
和expires_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
透過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 (略過)
每隔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端點接受通知,就是需要與驗證授權伺服器建立一個長連線。相對來說輪詢方式真的簡單很多。