以 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」開始,會進行下面的程序:

  1. 用戶在我們的 app 按下「Sign in with Twitter」
  2. 我們的 app 對 Twitter 發送一個 callback 網址及請求一組 request token
  3. Twitter 回覆 request token 以及認可 callback 網址
  4. 將用戶重導到 Twitter 的認證頁面
  5. 用戶授權我們的 app 取得他的 access token
  6. Twitte 回覆同樣的 request token 以及一個 request token verifier 給我們的 app
  7. 用戶從 Twitter 的認證頁被重導到 callback 網址
  8. 我們的 app 發送 request token 和 request token verifier 給 Twitter 要求用他們取得 access token
  9. Twitter 回覆 access token 與 access token secret
  10. 我們的 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 流程,如果把前後端分離的狀況也考慮進來,那流程會更複雜:

流程如下:

  1. 用戶在前端按下「Sign in with Twitter」後,後端向 Twitter 索取 request token 並得到 Twitter 認證頁的網址,把 Twitter 認證頁網址丟給前端,前端把用戶導到 Twitter 認證頁。
  2. 用戶在 Twitter 認證頁按下 Sign In,Twitter 把用戶導到我們指定的 callback URL,並得到 request token verifier,前端把 request token verifier 傳給後端,後端拿 request token 和 request token verifier 向 Twitter 索取用戶的 access token。
  3. 得到用戶的 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 應用的場景,在花時間閱讀前請謹慎評估,雖然你已經看到最末了。

34