以 Authlib 實現 OAuth 1 的 Twitter 登入

雖然 Twitter 在台灣一直不是主流,不過在國外 Twitter 是可以與 FB 相抗衡的平台之一,而目前主流的第三方認證機制 OAuth 最初也是由 Twitter 設計的,不過相較於 LINE、Google、FB 都已經採用 OAuth 2 標準做為第三方登入的機制,反而 Twitter 是少數還在用 OAuth 1 的業者,這裡我們會用 Authlib 套件來實做 Twitter 登入,並且簡單講講 OAuth 1 的認證流程(複雜的我也講不了)。
OAuth 1
這裡的 OAuth 1 主要是參考 Twitter 的文件而來,畢竟現在似乎只剩下 Twitter 還在用 OAuth 1 了⋯⋯吧?!
我們從大流程講起,從用戶按下「Sign in with Twitter」開始,會進行下面的程序:
  • 用戶在我們的 app 按下「Sign in with Twitter」
  • 我們的 app 對 Twitter 發送一個 callback 網址及請求一組 request token
  • Twitter 回覆 request token 以及認可 callback 網址
  • 將用戶重導到 Twitter 的認證頁面
  • 用戶授權我們的 app 取得他的 access token
  • Twitte 回覆同樣的 request token 以及一個 request token verifier 給我們的 app
  • 用戶從 Twitter 的認證頁被重導到 callback 網址
  • 我們的 app 發送 request token 和 request token verifier 給 Twitter 要求用他們取得 access token
  • Twitter 回覆 access token 與 access token secret
  • 我們的 app 把用戶導向會員頁面
  • 也可以配圖看,順便伸展一下脖子:
    走這一大段路,最終要拿到的就是 access token,但得先拿到 request token 和 request token secret,而 request token secret 又得透過用戶認證取得,取得後再拿 request token & request token secret 向 Twitter 取得 access token。
    雖然寫起來彷彿經歷千辛萬苦,但實際上整個過程不超過十秒,如同滄海一粟,稍縱即逝。
    上面的流程實際上是簡化過的版本,真正 post 來 post 的去的資料要複雜的多,並且還要用 HMAC-SHA1 編碼的簽名做防偽,幸好各大語言早就都有 OAuth 套件可以直接使用,省去自己造輪子的功夫,所以我們不求甚解的直接跳到使用套件的部份。(技術債 + 1、time to market - 3,划算)
    對於 OAuth 1 細節有興趣的朋友,推薦這篇〈OAuth 1.0 簡介〉。
    以 Authlib 實現 Twitter 登入
    第一步是文書作業,到 Twitter Developer Portal 開立專案,啟用 3-legged OAuth,取得 Twitter API Key 以及 Twitter API Secret Key,這組 API key 在前面的 OAuth 1 流程稱為 consumer key,這種名稱不一致的情況在下文會持續發生,一個物件在 OAuth、Twitter、Authlib 內各自表述,就像台灣中國九二會談一樣毫無共識。
    下面的表格整理了三者命名的對應,希望不要被這些混亂的命名搞亂。
    OAuth
    命名
    Twitter
    命名
    Authlib
    命名
    用途
    Service Provider Twitter OAuth Server 提供 OAuth 認證的平台
    Consumer App OAuth Client 向 OAuth server 要求認證的 app
    Consumer Key API Key Client ID 存取 Twitter API
    Consumer Secret API Secret Key Client Secret 產生 HMAC-SHA1 防偽簽名
    Callback Callback URL Redirct URI 用戶認證後 Twitter 把用戶導向 app 的網址
    下文開始 Authlib 實現 Twitter 登入,以下的程式碼都執行在 Python shell 內,並且程式碼經過排版便於閱讀。
    引入 authlib 元件及定義一些基礎參數:
    from authlib.integrations.requests_client import OAuth1Session
    
    client_id: str      = 'my_client_id'        # TWITTER_API_KEY
    client_secret: str  = 'my_client_secret'    # TWITTER_API_SECRET_KEY
    
    client: OAuthSession = OAuth1Session(
        client_id       = client_id,
        client_secret   = client_secret
    )
    我們會用這個 client 與 Twitter API 做交流。上面的 client_idclient_secret 的值比較正規的做法應該用 python-dotenv 隱藏起來,但這裡就不講究那麼多了。
    client 去向 Twitter API 取得 request_token
    request_token_url: str = 'https://api.twitter.com/oauth/request_token'
    request_token:dict = client.fetch_request_token(url=request_token_url)
    
    print(request_token)
    
    # request_token
    {
        'oauth_token'               : 'my_oauth_token',
        'oauth_token_secret'        : 'my_auth_token_secret',
        'oauth_callback_confirmed'  : 'true'
    }
    注意到在流程圖有提到要求 request token 時要傳送一個 callback URL,但是在此並沒有指定,Twitter 會用 Twitter Developer Portal 內的 Callback URLs 的第一個值,所以不用在程式內指定,想在程式內指定也是可以的,以 staging 環境為例:
    from authlib.integrations.requests_client import OAuth1Session
    
    client_id: str      = 'my_client_id'        # TWITTER_API_KEY
    client_secret: str  = 'my_client_secret'    # TWITTER_API_SECRET_KEY
    redirect_uri        = 'https://staging.example.com/auth/twitter'
    
    client: OAuthSession = OAuth1Session(
        client_id       = client_id,
        client_secret   = client_secret,
        redirect_uri    = redirect_uri
    )
    不過這裡的 redirect_uri 的網址也還是得要事先登錄到 Twitter Developer Portal 的 Callback URLs,否則是會被 Twitter API 阻擋的。
    和 Twitter API 的交互之所以看起來這麼簡單是 Authlib 的功勞,它幫我們做掉了 OAuth 1 要求的其他欄位的值以及生成防偽簽章的苦工,只留下真正必要的參數給我們填入。
    用剛拿到的 request_token 去組出一個 Twitter 用戶認證網址:
    oauth_token: str = request_token['oauth_token']
    oauth_token_secret: str = request_token['oauth_token_secret']
    
    authenticate_url: str = 'https://api.twitter.com/oauth/authenticate'
    
    client.create_authorization_url(
        url             = authenticate_url,
        request_token   = oauth_token
    )
    
    # Authorization URL
    'https://api.twitter.com/oauth/authenticate?oauth_token=my_oauth_token'
    得到 authorization URL 後,把用戶導過去,用戶會看到 Twitter 的認證畫面:
    用戶按下 Sing In 後,Twitter 會把用戶重導到我們指定的 callback URL,並附上我們的 oauth_tokenoauth_verifier
    假設我們的 app 在 Twitter Developer Portal 登錄的 callback URL 是 https://example.com/auth/twitter,Twitter 導引用戶回來時會在 callback URL 後附加 oauth_tokenoauth_verifier 兩個參數,因此收到的請求是這樣的:
    resp_url: str = 'https://example.com/auth/twitter?oauth_token=my_oauth_token&oauth_verifier=my_oauth_verifier'
    再拿 oauth_tokenoauth_verifier 去向 Twitter API 要 access token:
    client.parse_authorization_response(resp_url)
    access_token_url: str = 'https://api.twitter.com/oauth/access_token'
    token: dict = client.fetch_access_token(access_token_url)
    
    print(token)
    
    # token
    {
        'oauth_token'       : 'user_oauth_token',
        'oauth_token_secret': 'user_oauth_token_secret',
        'user_id'           : 'user_user_id',
        'screen_name'       : 'user_screen_name'
    }
    注意,最後拿到的 oauth_tokenoauth_token_secret 是代表那位用戶的,而在此之前和 Twitter API 交互的 oauth_token 是屬於我們的 app 的,名字一樣但意義不同。
    最後拿到的代表用戶的 oauth_tokenoauth_token_secretuser_idscreen_name 應該存入我們自己的會員資料庫內,做為後續認證之用,但要注意的是不要拿 screen_name 辨識用戶,因為 Twitter 的用戶名是可以改的。
    有了用戶授權給我們的 access token,除了讓我們做第三方登入外,也可以拿 access token 去做其他的應用,例如獲得用戶帳號的其它資料,用的是 verify_credentials 這支 API:
    twitter_account_verify_credentials_uri = 'https://api.twitter.com/1.1/account/verify_credentials.json'
    
    resp = client.get(
        url     = twitter_account_verify_credentials_uri,
        params  = {'skip_status': True}
    )
    
    print(resp.json())
    
    # resp.json()
    {
        ...
        'protected': False,
        'friends_count': 5,
        'favourites_count': 9,
        'utc_offset': None,
        'time_zone': None,
        'geo_enabled': False,
        'verified': False,
        'statuses_count': 7,
        'lang': None,
        'contributors_enabled': False,
        'is_translator': False,
        'is_translation_enabled': False,
        'profile_background_tile': False,
        'profile_use_background_image': True,
        'has_extended_profile': True,
        'default_profile': True,
        'default_profile_image': False,
        'following': False,
        'follow_request_sent': False,
        'notifications': False,
        'translator_type': 'none',
        'suspended': False,
        'needs_phone_verification': False,
        ...
    }
    當然能拿到的資料僅限於用戶在 Twitter 認證頁同意授權給我們的部份,另外在 Twitter Developer Portal 也要對額外要求的用戶資料目的提交給 Twitter 審查,以最基本的用戶 email 為例,就要提供 terms of service 和 privacy policy 給 Twitter 才能取得。
    Authlib 和 Web 框架的整合
    前面的範例是以 Authlib 做為 OAuth client 實做 OAuth 1 流程的程式碼,目的是親自實證 OAuth 1 的交互流程,而在真實的 web 後端應用場景,Authlib 有更好的與 web 框架整合好的 API 可以使用,目前 Authlib 已有與 Starlette、FastAPI、Flask、Django 的整合模組。
    以 Starlette 為例,一個最陽春的 Starlette web app 大概長這樣:
    from starlette.applications import Starlette
    from starlette.requests import Request
    
    app = Starlette()
    先幫它加上 authlib 的元件及初始化:
    from starlette.applications import Starlette
    from starlette.requests import Request
    from authlib.integrations.starlette_client import OAuth
    
    app = Starlette()
    oauth = OAuth()
    再加上 OAuth server 的各個基本 API 網址:
    from starlette.applications import Starlette
    from starlette.requests import Request
    from authlib.integrations.starlette_client import OAuth
    
    app = Starlette()
    oauth = OAuth()
    
    oauth.register(
        name                    = 'twitter',
        api_base_url            = 'https://api.twitter.com/1.1/',
        request_token_url       = 'https://api.twitter.com/oauth/request_token',
        request_token_params    = None,
        access_token_url        = 'https://api.twitter.com/oauth/access_token',
        access_token_params     = None,
        authorize_url           = 'https://api.twitter.com/oauth/authenticate',
        aurhorize_params        = None,
        client_id               = 'my_client_id',
        client_secret           = 'my_client_secret',
        client_kwargs           = None,
    )
    因為 OAuth 1 是標準化的協議,所以必然會有 request_token_urlaccess_token_urlauthorize_url,也必然會有 client_idclient_secret 兩個值,儘管命名可能不一樣。
    再次提醒,client_idclient_secret 的值應該要放在 .env 內,這裡只是示範才省略。
    加上兩個 route,一個是讓用戶按下 Sign in with Twitter 的網址,一個是讓 Twitter callback 回來的網址:
    from starlette.applications import Starlette
    from starlette.requests import Request
    from authlib.integrations.starlette_client import OAuth
    
    app = Starlette()
    oauth = OAuth()
    
    oauth.register(
        name                    = 'twitter',
        api_base_url            = 'https://api.twitter.com/1.1/',
        request_token_url       = 'https://api.twitter.com/oauth/request_token',
        request_token_params    = None,
        access_token_url        = 'https://api.twitter.com/oauth/access_token',
        access_token_params     = None,
        authorize_url           = 'https://api.twitter.com/oauth/authenticate',
        aurhorize_params        = None,
        client_id               = 'my_client_id',
        client_secret           = 'my_client_secret',
        client_kwargs           = None,
    )
    
    @app.route('/login/twitter')
    async def login_via_twitter(request: Request):
        twitter = oauth.create_client('twitter')
        redirect_uri = request.url_for('authorize_twitter')
        return await twitter.authorize_redirect(request, redirect_uri)
    
    @app.route('/auth/twitter')
    async def authorize_twitter(request: Request):
        twitter = oauth.create_client('twitter')
        token = await twitter.authorize_access_token(request)
        user = await twitter.parse_id_token(request, token)
        return user
    Authlib 對 web 框架的整合封裝度更高,前面我們還要手動照 OAuth 1 流程跑,取得 request token 什麼的,這裡完全不用,一個 authorize_redirect() 就把拿 request token 和導引用戶到 Twitter 認證頁的工作全做掉了,後面的 callback 網址收到 Twitter 導回來的用戶也是一個函式 authorize_access_token() 搞定前面手動拿 token 交換來交換去的工作,真心感謝 Authlib 大大,祝 Authlib 大大好人一生平安。
    最後,Authlib 有用到 Starlette 的 session middleware 管理用戶從我們的 app 跳轉到 Twitter 又跳回來的 session,所以再把 session middleware 加進來:
    from starlette.applications import Starlette
    from starlette.requests import Request
    from starlette.middleware.sessions import SessionMiddleware
    from authlib.integrations.starlette_client import OAuth
    
    app = Starlette()
    app.add_middleware(SessionMiddleware, secret_key="㊙️")
    
    oauth = OAuth()
    
    oauth.register(
        name                    = 'twitter',
        api_base_url            = 'https://api.twitter.com/1.1/',
        request_token_url       = 'https://api.twitter.com/oauth/request_token',
        request_token_params    = None,
        access_token_url        = 'https://api.twitter.com/oauth/access_token',
        access_token_params     = None,
        authorize_url           = 'https://api.twitter.com/oauth/authenticate',
        aurhorize_params        = None,
        client_id               = 'my_client_id',
        client_secret           = 'my_client_secret',
        client_kwargs           = None,
    )
    
    @app.route('/login/twitter')
    async def login_via_twitter(request: Request):
        twitter = oauth.create_client('twitter')
        redirect_uri = request.url_for('authorize_twitter')
        return await twitter.authorize_redirect(request, redirect_uri)
    
    @app.route('/auth/twitter')
    async def authorize_twitter(request: Request):
        twitter = oauth.create_client('twitter')
        token = await twitter.authorize_access_token(request)
        user = await twitter.parse_id_token(request, token)
        return user
    這裡是以 Starlette 做示範,在其他的 web 框架上也差不多,三招搞定 OAuth client:
  • register()
  • authorize_redirect()
  • authorize_access_token()
  • 如果是以 Starlette 為基礎的 FastAPI,那上面那塊程式碼更是可以幾乎完全套用。
    前後端分離下的 OAuth 1 流程
    上面我們談到 web 後端的 OAuth 1 流程,如果把前後端分離的狀況也考慮進來,那流程會更複雜:
    流程如下:
  • 用戶在前端按下「Sign in with Twitter」後,後端向 Twitter 索取 request token 並得到 Twitter 認證頁的網址,把 Twitter 認證頁網址丟給前端,前端把用戶導到 Twitter 認證頁。
  • 用戶在 Twitter 認證頁按下 Sign In,Twitter 把用戶導到我們指定的 callback URL,並得到 request token verifier,前端把 request token verifier 傳給後端,後端拿 request token 和 request token verifier 向 Twitter 索取用戶的 access token。
  • 得到用戶的 access token 後,後端產生自己的 bearer token 交給前端,至此用戶登入完畢,爾後前端發送到後端的請求都附上 bearer token 識別身份,這裡的 bearer token 是 OAuth 2 的 bearer token,也就是說在我們的前端和後端之間,又走了最陽春的 OAuth 2 的認證機制,這部分並非本文的重點,在此點到為止,後續有機會再分享此處的細節。
  • 這個流程的特點是 consumer key、consumer secret、request token secret、access token 都保留在後端,暴露到前端的則有 request token 和 request token verifier,因此:
  • 整套 OAuth 1 流程中,用於簽名防偽的主角是 consumer secret,它是始終只在後端的,可以保障前端即使被植入惡意程式碼也無法自行對 Twitter 溝通。
  • 用戶的 access token 也只在後端,同樣的保障了 access token 被盜的風險。
  • 最後用於前後端認證的是我們自己發行的 bearer token(JWT),同樣的,它也有簽名防偽的機制,後端得以驗證前端的請求是否被竄改過,藉此保證了前端請求的真實性。
  • 然而上述的流程並非唯一選項,有人也把和 Twitter OAuth 1 交互的部份都做在前端,這部份可以參閱〈Demystifying authentication with FastAPI and a frontend〉。
    結語
    雖然 Authlib 把 OAuth 複雜的細節封裝成很簡單的三大函式,但對不了解 OAuth 1 流程的開發者來說,恐怕會陷入知其然卻不知期所以然的狀態,所以很不幸的,還是得回頭去認識 OAuth 1 的交互流程。
    另外本篇的最開頭有提到,Twitter 是目前僅存仍在使用 OAuth 1 的大戶,而從 Twitter 開發人員的網站上也可以看到,Twitter 正在逐步向 OAuth 2 遷移,所以本文很有可能再過一季就會成為廢文,屆時再也沒有 OAuth 1 應用的場景,在花時間閱讀前請謹慎評估,雖然你已經看到最末了。

    48

    This website collects cookies to deliver better user experience

    以 Authlib 實現 OAuth 1 的 Twitter 登入