在Flask程式中實現GitHub登入和GitHub資源互動

greylihui發表於2019-02-18

本文基於《Flask Web開發實戰》(噹噹滿100減50)的刪減內容改寫而來,更多Flask文章和開源專案可以訪問helloflask.com檢視。

執行示例程式

本文示例程式的原始碼託管在GitHub上(helloflask/github-login)。執行示例程式的步驟如下:

$ git clone https://github.com/helloflask/github-login.git
$ cd github-login
$ pipenv install  # 如果沒有安裝pipenv,那麼執行pip install pipenv安裝
$ flask run  # 在此之前需要在GitHub註冊OAuth程式並將客戶端ID與金鑰寫入程式,具體見上文複製程式碼

提示 如果你想直接體驗程式,可以訪問線上Demo

第三方登入

簡單來說,為一個網站新增第三方登入指的是提供通過其他第三方平臺賬號登入當前網站的功能。比如,使用QQ、微信、新浪微博賬號登入。對於某些網站,甚至可以僅提供社交賬號登入的選項,這樣網站本身就不需要管理使用者賬戶等相關資訊。對使用者來說,使用第三方登入可以省去註冊的步驟,更加方便和快捷。

使用GitHub-Flask實現GitHub第三方登入

如果專案和GitHub、開源專案、程式語言等方面相關,或是面向的主要使用者群是程式設計師時,可以僅支援GitHub的第三方登入,比如Gitter、GitBook、Coveralls和Travis CI等。在Flask程式中,除了手動實現,我們可以藉助其他擴充套件或庫,我們在這篇文章裡要使用的GitHub-Flask擴充套件專門用於實現GitHub第三方登入,以及與GitHub進行Web API資源互動。

附註 第三方登入的原理是與第三方服務進行OAuth認證互動的,這裡不會詳細介紹OAuth,具體可以閱讀OAuth官網列出的資源。

第三方登入授權流程

起這個標題是為了更好理解,具體來說,整個流程實際上是指OAuth2中Authorization Code模式的授權流程。為了便於理解,這裡按照實際操作順序列出了整個授權流程的實現步驟:

  1. 在GitHub為我們的程式註冊OAuth程式,獲得Client ID(客戶端ID)和Client Secret(客戶端金鑰)。
  2. 我們在登入頁面新增“使用GitHub登入”按鈕,按鈕的URL指向GitHub提供的授權URL,即github.com/login/oauth…
  3. 使用者點選登入按鈕,程式訪問GitHub的授權URL,我們在授權URL後附加查詢引數Client ID以及可選的Scope等。GitHub會根據授權URL中的Client ID識別出我們的程式資訊,根據scope獲取請求的許可權範圍,最後把這些資訊顯示在授權頁面上。
  4. 使用者輸入GitHub的賬戶及密碼,同意授權
  5. 使用者同意授權後GitHub會將使用者重定向到我們註冊OAuth程式時提供的回撥URL。如果使用者同意授權,回撥URL中會附加一個code(即Authorization Code,通常稱為授權碼),用來交換access令牌(即訪問令牌,也被稱為登入令牌、存取令牌等)。
  6. 我們在程式中接受到這個回撥請求,獲取code,傳送一個POST請求到用於獲取access令牌的URL,並附加Client ID、Client Secret和code值以及其他可選的值。
  7. GitHub接收到請求後,驗證code值,成功後會再次向回撥URL發起請求,同時在URL的查詢字串中或請求主體中加入access令牌的值、過期時間、token型別等資訊。
  8. 我們的程式獲取access令牌,可以用於後續發起API資源呼叫,或儲存到資料庫備用
  9. 如果使用者是第一次登入,就建立使用者物件並儲存到資料庫,最後登入使用者
  10. 這裡可選的步驟是讓使用者設定密碼或資料

在GitHub註冊OAuth程式

和其他主流第三方服務相同,GitHub使用OAuth2中的Authorization Code模式認證。因為認證後,根據授權的許可權,客戶端可以獲取到使用者的資源,為了便於對客戶端進行識別和限制,我們需要在GitHub上進行註冊,獲取到客戶端ID和金鑰才能進行OAuth授權。

在服務提供方的網站上進行OAuth程式註冊時,通常需要提供程式的基本資訊,比如程式的名稱、描述、主頁等,這些資訊會顯示在要求使用者授權的頁面上,供使用者識別。在GitHub中進行OAuth程式註冊非常簡單,訪問github.com/settings/ap…填寫登錄檔單(如果你沒有GitHub賬戶,那麼需要先註冊一個才能訪問這個頁面。),登錄檔單各個欄位的作用和示例如圖所示:

在Flask程式中實現GitHub登入和GitHub資源互動

表單中的資訊都可以後續進行修改。在開發時,程式的名稱、主頁和描述可以使用臨時的佔位內容。但Callback URL(回撥URL)需要正確填寫,這個回撥URL用來在使用者確認授權後重定向到程式中。因為我們需要在本地開發時進行測試,所以需要填寫本地程式的URL,比如http://127.0.0.1:5000/callback/github,我們需要建立處理這個請求的檢視函式,在這個檢視函式中獲取回撥URL附加的資訊,後面會詳細介紹。

注意 這裡因為是在開發時進行本地測試,所以填寫了程式執行的地址,在生產環境要避免指定埠。另外,在這裡localhost和127.0.0.1將會被視為兩個地址。在程式部署上線時,你需要將這些地址更換為真實的網站域名地址。

註冊成功後,我們會在重定向後的頁面看到我們的Client ID(客戶端ID)和Client Secret(客戶端金鑰),我們需要將這兩個值分別賦值給配置變數GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET:
GITHUB_CLIENT_ID = `GitHub客戶端ID`
GITHUB_CLIENT_SECRET = `GitHub客戶端金鑰`複製程式碼
注意 示例程式中為了便於測試,直接在指令碼中寫出了,在生產環境下,你應該將它們寫入到環境變數,然後在指令碼中從環境變數讀取。

安裝並初始化GitHub-Flask

首先使用pip或Pipenv等工具安裝GitHub-Flask:

$ pip install github-flask複製程式碼
和其他擴充套件類似,你可以使用下面的方式初始化擴充套件(注意擴充套件類大小寫):
from flask import Flask
from flask_github import GitHub

app = Flask(__name__)
github = GitHub(app)複製程式碼
如果你使用工廠函式建立程式,那麼可以使用下面的方式初始化擴充套件:
from flask import Flask
from flask_github import GitHub

github = GitHub()
... 

def create_app():
    app = Flask(__name__)
    github.init_app(app)
    ...
    return app複製程式碼

注意 雖然副檔名稱是GitHub-Flask,但實際的包名稱仍然是flask_github(Flask副檔名稱可以倒置(即“Foo-Flask”),但包名稱的形式必須為“flask_foo“。)。另外要注意擴充套件類的拼寫,其中H為大寫。

準備工作

在示例程式中,我們首先進行了下面的基礎工作:
  • 定義基本配置
  • 建立一個簡單的使用者模型來儲存使用者資訊(使用Flask-SQLAlchemy)
  • 實現登入和登出的管理功能(使用session實現,可以使用Flask-Login簡化)
  • 建立用於初始化資料庫的命令函式
app = Flask(__name__)

app.config[`SECRET_KEY`] = os.getenv(`SECRET_KEY`, `secret string`)
# Flask-SQLAlchemy
app.config[`SQLALCHEMY_DATABASE_URI`] = `sqlite:///` + os.path.join(app.root_path, `data.db`)
app.config[`SQLALCHEMY_TRACK_MODIFICATIONS`] = False

db = SQLAlchemy(app)

# 命令函式
@app.cli.command()
@click.option(`--drop`, is_flag=True, help=`Create after drop.`)
def initdb(drop):
    """Initialize the database."""
    if drop:
        db.drop_all()
    db.create_all()
    click.echo(`Initialized database.`)

# 儲存使用者資訊的資料庫模型類
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(100))  # 使用者名稱
    access_token = db.Column(db.String(200))  # 授權完成後獲取的訪問令牌

# 管理每個請求的登入狀態,如果已登入(session裡有使用者id值),將模型類物件儲存到g物件中
@app.before_request
def before_request():
    g.user = None
    if `user_id` in session:
        g.user = User.query.get(session[`user_id`])

# 登入
@app.route(`/login`)
def login():
    if session.get(`user_id`, None) is None:
        ...  # 進行OAuth授權流程,具體見後面
    flash(`Already logged in.`)
    return redirect(url_for(`index`))

# 登出
@app.route(`/logout`)
def logout():
    session.pop(`user_id`, None)
    flash(`Goodbye.`)
    return redirect(url_for(`index`))複製程式碼

現在我們可以執行上面建立的initdb命令來建立資料庫和表(確保當前目錄在demos/github-

login下):
$ flask initdb複製程式碼

建立登入按鈕

我們在本節一開始詳細描述了以GitHub為例的完整的OAuth授權的過程,現在讓我們來建立登入按鈕。示例程式非常簡單,只包含一個主頁(index.html),這個頁面由index檢視處理:

@app.route(`/`)
def index():
    is_login = True if g.user else False  # 判斷使用者登入狀態
    return render_template(`index.html`, is_login=is_login)複製程式碼

這個檢視在渲染模板時傳入了用於判斷使用者登入狀態的is_login變數,我們在模板中根據這個變數渲染不同的元素,如果已經登入,顯示退出按鈕,否則顯示登入按鈕:

{% if is_login %}
    <a class="btn" href="{{ url_for(`logout`) }}">Logout</a>
{% else %}
    <a class="btn" href="{{ url_for(`login`) }}">Login with GitHub</a>
{% endif %}複製程式碼
未登入情況下的主頁如下圖所示:

在Flask程式中實現GitHub登入和GitHub資源互動

在實際的專案中,你可以使用GitHub的logo來讓登入按鈕更好看一些。

提示 使用Flask-Login時,你可以直接在模板中通過current_user.is_authenticated屬性來判斷使用者登入狀態。

傳送授權請求

這個登入按鈕的URL指向的是login檢視,這個檢視用來傳送授權請求,如下所示:

@app.route(`/login`)
def login():
    if session.get(`user_id`, None) is None:  # 判斷使用者登入狀態
        return github.authorize(scope=`repo`)
    flash(`Already logged in.`)
    return redirect(url_for(`index`))複製程式碼

在這個檢視中,如果使用者沒有登入,我們就呼叫github.authorize()方法。這個方法會生成授權URL,並向這個URL傳送請求。

附註 GitHub-Flask擴內建了除了客戶端ID和金鑰外所有必要的URL,比如API的URL,獲取訪問令牌的URL等(我們也可以通過相應的配置鍵進行修改,具體參考GitHub-Flask的文件)。

發起認證請求的URL中必須加入的引數是客戶端ID,GitHub-Flask會自動使用我們之前通過配置變數傳入的值。在授權URL中附加的可選引數如下所示:
在Flask程式中實現GitHub登入和GitHub資源互動

這三個引數都可以在呼叫github.authorize()方法時使用對應的名稱作為關鍵字引數傳入。

如果不設定scope,GitHub-Flask擴充套件預設設定為None,那麼會擁有的許可權是獲取使用者的公開資訊。但是因為我們需要測試為專案加星(star)的操作,所以需要請求名為repo的許可權值。

附註 選擇scope時儘量只選擇需要的內容,申請太多的許可權可能會被使用者拒絕。GitHub提供的所有的可用scope列表及其說明可以在GitHub開發文件看到。

如果不設定redirect_uri,那麼GitHub會使用我們填寫的callback URL。但是需要注意的是,如果我們填寫了,那就必須和註冊程式時填寫的URL完全相同。我們在這裡沒有指定,因此將會使用註冊OAuth程式時設定的http://localhost:5000/callback/github

獲取access令牌(訪問令牌)

現在程式會重定向到GitHub的授權頁面(會先要求登入GitHub),如下圖所示:

在Flask程式中實現GitHub登入和GitHub資源互動

當使用者同意授權或拒絕授權後,GitHub會將使用者重定向到我們設定的callback URL,我們需要建立一個檢視函式來處理回撥請求。如果使用者同意授權,GitHub會在重定向的請求中加入code引數,一個臨時生成的值,用於程式再次發起請求交換access token。程式這時需要向請求訪問令牌URL(即https://github.com/login/oauth/access_token)發起一個POST請求,附帶客戶端ID、客戶端金鑰、code以及可選的redirect_uri和state。請求成功後的的響應會包含訪問令牌(Access Token)。

很幸運,上面的一系列工作GitHub-Flask會在背後替我們完成。我們只需要建立一個檢視函式,定義正確的URL規則(這裡的URL規則需要和GitHub上填寫的Callback URL匹配),併為其附加一個github.authorized_handler裝飾器。另外,這個函式要接受一個access_token引數,GitHub-Flask會在授權請求結束後通過這個引數傳入訪問令牌,如下所示:
@app.route(`/callback/github`)
@github.authorized_handler
def authorized(access_token):
    if access_token is None:
        flash(`Login failed.`)
        return redirect(url_for(`index`))
    # 下面會進行建立新使用者,儲存訪問令牌,登入使用者等操作,具體見後面
    ...
    return redirect(url_for(`chat.app`))複製程式碼
接受到GitHub返回的響應後,GitHub-Flask會呼叫這個authorized()函式,並傳入access_token的值。如果授權失敗,access_token的值會是None,這時我們重定向到主頁頁面,並顯示一個錯誤訊息。如果access_token不為None,我們會進行建立新使用者,儲存訪問令牌,登入使用者等操作,具體見下一節。

獲取和操作使用者在GitHub上的資源

在獲取到訪問令牌後,我們需要做下面的工作:
  • 判斷使用者是否已經存在於資料庫中,如果存在就登入使用者,更新訪問令牌值(因為access是有過期時間的)
  • 如果資料庫中沒有該使用者,那麼建立一個新的使用者記錄,傳入對應的資料,最後登入使用者

在這個示例程式中,我們使用使用者名稱(username)作為使用者的唯一標識,為了從資料庫中查詢對應的使用者,我們需要獲取使用者在GitHub上的使用者名稱。

如果授權成功,那麼我們就使用這個訪問令牌向GitHub提供的Web API的/user端點發起一次GET請求。這可以通過GitHub-Flask提供的get()方法實現,傳入訪問令牌作為access_token引數的值。我們把表示使用者的資源端點“user”傳入get()方法,因為GitHub-Flask會自動補全完整的請求URL,即api.github.com/user
response = github.get(`user`, access_token=access_token)複製程式碼
提示 GitHub-Flask提供了一系列方法來呼叫GitHub通過Web API開放的資源。和在jQuery為AJAX提供的方法類似,它提供了底層的request()方法和方便的get()、post()、put()、delete()等方法(這些方法內部會呼叫request方法),可以用來傳送不同HTTP方法的請求。

/user端點對應使用者資料,返回的JSON資料如下所示:

{
 "avatar_url": "https://avatars3.githubusercontent.com/u/12967000?v=4", 
 "bio": null, 
 "blog": "greyli.com", 
 "company": "None", 
 "created_at": "2015-06-19T13:00:23Z", 
 "email": "withlihui@gmail.com", 
 "events_url": "https://api.github.com/users/greyli/events{/privacy}", 
 "followers": 132, 
 "followers_url": "https://api.github.com/users/greyli/followers", 
 "following": 8, 
 "following_url": "https://api.github.com/users/greyli/following{/other_user}", 
 "gists_url": "https://api.github.com/users/greyli/gists{/gist_id}", 
 "gravatar_id": "", 
 "hireable": true, 
 "html_url": "https://github.com/greyli", 
 "id": 12967000, 
 "location": "China", 
 "login": "greyli", 
 "name": "Grey Li", 
 "node_id": "MDQ6VXNlcjEyOTY3MDAw", 
 "organizations_url": "https://api.github.com/users/greyli/orgs", 
 "public_gists": 7, 
 "public_repos": 61, 
 "received_events_url": "https://api.github.com/users/greyli/received_events", 
 "repos_url": "https://api.github.com/users/greyli/repos", 
 "site_admin": false, 
 "starred_url": "https://api.github.com/users/greyli/starred{/owner}{/repo}", 
 "subscriptions_url": "https://api.github.com/users/greyli/subscriptions", 
 "type": "User", 
 "updated_at": "2018-06-24T02:05:38Z", 
 "url": "https://api.github.com/users/greyli"
}複製程式碼

附註 使用者端點返回的響應示例以及其他所有開放的資源端點可以在GitHub的API文件(developer.github.com/v3/)中看到。

GitHub-Flask會把GitHub的JSON響應主體解析為一個字典並返回,我們使用對應的鍵獲取這些資料。其中登入使用者名稱使用login作為鍵獲取:
username = response[`login`]複製程式碼
獲取到使用者名稱後,我們判斷是否已存在該使用者,如果存在更新access_token欄位值;如果不存在則建立一個新的User例項,把使用者名稱和訪問令牌儲存到使用者模型的對應欄位裡:
user = User.query.filter_by(username=username).first()
if user is None:
    user = User(username=username, access_token=access_token)
    db.session.add(user)
 user.access_token = access_token # update access token
 db.session.commit()複製程式碼
最後,我們登入對應的使用者物件或是新建立的使用者物件(將使用者id寫入session):
flash(`Login success.`)
# log the user in
# if you use flask-login, just call login_user() here.
session[`user_id`] = user.id複製程式碼
因為我們需要在其他檢視裡呼叫GitHub資源,為了避免每次都獲取和傳入訪問令牌,我們可以使用github.access_token_getter裝飾器建立一個統一的令牌獲取函式:
@github.access_token_getter
def token_getter():
    user = g.user
    if user is not None:
        return user.access_token複製程式碼

當你在某處直接使用github.get()等方法而不傳入訪問令牌時,GitHub-Flask會通過你提供的這個回撥函式來獲取訪問令牌。

注意 雖然在很多開源庫的示例程式中,都會把access令牌儲存到session中,但session不能用來儲存敏感資訊(具體可以訪問這篇文章瞭解)。所以除了作測試用途,在生產環境下正確的做法是把訪問令牌儲存到資料庫中。

現在,我們的主頁檢視需要更新,對於登入的使用者,我們將會顯示使用者在GitHub上的資料:

@app.route(`/`)
def index():
    if g.user:
        is_login = True
        response = github.get(`user`)
        avatar = response[`avatar_url`]
        username = response[`name`]
        url = response[`html_url`]
        return render_template(`index.html`, is_login=is_login, avatar=avatar, username=username, url=url)
    is_login = False
    return render_template(`index.html`, is_login=is_login)複製程式碼

類似的,我們使用github.get()方法獲取/user端點的使用者資料,因為設定了令牌獲取函式,所以不用顯式的傳入訪問令牌值。這些資料(頭像、顯示使用者名稱和GitHub使用者主頁URL)將會顯示在主頁,如下圖所示:

在Flask程式中實現GitHub登入和GitHub資源互動

因為我們在進行授權時請求了repo許可權,我們還可以對使用者的倉庫進行各類操作,示例程式中新增了一個加星的示例,如果你登入後點選主頁的“Star HelloFlask on GitHub”按鈕,就會加星對應的倉庫。這個按鈕指向的star檢視如下所示:

@app.route(`/star/helloflask`)
def star():
    github.put(`user/starred/greyli/helloflask`, headers={`Content-Length`: `0`})
    flash(`Star success.`)
    return redirect(url_for(`index`))複製程式碼
完整的用於處理回撥請求的authorized()檢視函式如下所示:
@app.route(`/callback/github`)
@github.authorized_handler
def authorized(access_token):
    if access_token is None:
        flash(`Login failed.`)
        return redirect(url_for(`index`))

    response = github.get(`user`, access_token=access_token)
    username = response[`login`] # get username
    user = User.query.filter_by(username=username).first()
    if user is None:
        user = User(username=username, access_token=access_token)
        db.session.add(user)
    user.access_token = access_token # update access token
    db.session.commit()
    flash(`Login success.`)
    # log the user in
    # if you use flask-login, just call login_user() here.
    session[`user_id`] = user.id
    return redirect(url_for(`index`))複製程式碼

走進現實

一次完整的OAuth認證就這樣完成了。在實際的專案中,支援第三方登入後,我們需要對原有的登入系統進行調整。通過第三方認證建立的使用者沒有密碼,所以如果這部分使用者使用傳統方式登入的話會出現錯誤。我們新增一個if判斷,如果使用者物件的password_hash欄位(儲存密碼雜湊值)為空時,我們會返回一個錯誤提示,提醒使用者使用上次使用的第三方服務進行登入,比如:

@app.route(`/login`, methods=[`GET`, `POST`])
def login():
    ...
    if request.method == `POST`:
        ...
        user = User.query.filter_by(email=email).first()

        if user is not None:
            if user.password_hash is None:
                flash(`Please use the third patry service to log in.`)
                return redirect(url_for(`.login`))
        ...複製程式碼
如果你想讓使用者也可以直接使用賬戶密碼登入,那麼可以在授權成功後重定向到新的頁面請求使用者設定密碼。

相關連結

相關文章