1. 程式人生 > >Django oauth toolkit + Android + Retrofit 實現 OAuth2 的 access token 獲取

Django oauth toolkit + Android + Retrofit 實現 OAuth2 的 access token 獲取

概述

最近在做一個Android App,需要從一個Django部署的伺服器上讀取使用者的個人資訊。擬使用OAuth2作為授權的方案,簡單搜尋之後發現Django有一個oauth toolkit的專案,於是就使用了oauth toolkit。在Android系統上,GET和POST等網路操作都交給了Retrofit來完成。這個blog就簡單描述一下server side, client side的實現方案,最後給出一個例項。

Authorization server

在OAuth2 的體系中,需要有一個authorization server,用於註冊App、使用者登入驗證、使用者授權和access token的分發。我的這個authorization serve除錯時即為本機,後端執行的是Django,安裝了oauth toolkit,前期也進行了簡單除錯。為了完成當前的需求,在authorization server上做了幾個主要的調整。

(1)需要使用Django自己的User model。

在學習django oauth toolkit時發現,預設情況下oauth toolkit使用Django預設的User model。我目前開發的專案使用的是自定義的User model,之前也嘗試了將Django的admin系統配置成custom user model但是最終還是沒有成功。目前使用的方案是在custom user model中做一個foreign key,索引到Django自己的User model。

(2)設定上新增本地ip作為allowed server。

為了Android studio的ADV可以與本機的伺服器通訊,需要使用本機在區域網中的實際ip地址。配置Django專案的settings.py檔案,將本機當前的區域網ip地址加入到ALLOWED_HOSTS裡。

(3)修改ALLOWED_REDIRECT_URI_SCHEMES。

為了方便authorization server發回authorization code時Android OS可以直接將Intent傳送給我們的App,我們定義redirect uri時最好使用一個custom scheme。一般這個scheme可以是任何有意義的標識,例如公司名,我使用的scheme是huyaoyu,最後redirect uri為huyaoyu://callback。在App的Manifest中新增一個針對這個uri的itent-filter,那麼當authorization server返回authorization code時便可以直接得到App的處理。

以上方案的實現,依賴於oauth toolkit支援自定義的scheme。oauth toolkit預設的scheme為http和https。修改預設scheme的方法是在Django的settings.py檔案中增加oauth toolkit的配置,具體如下。

OAUTH2_PROVIDER = {
    'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https', 'huyaoyu'],
}

即將ALLOWED_REDIRECT_URI_SCHEMES定義為增加對huyaoyu的支援。參考了

(4)REST framework。

在Django框架下使用了REST框架。具體配置參考這裡

但需要注意的是,需將所有oauth2_provider.ext改為oauth2_provider.contrib。這個修正參考了

(5)描述各個關鍵URL。

oauth toolkit提供了一組預設的URL,在當前的專案中通過修改專案總體urls.py來新增相應的url。

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include(router.urls)),
    url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
    url(r'^accounts/', include('django.contrib.auth.urls')),
]

注意以上urlpatterns變數,省略了其他Django application的引用。並且最後一行是關鍵,oauth toolkit預設將會引導使用者到accounts/地址進行登入,此處需要按照上述形式書寫。

註冊App。

正確配置oauth toolkit之後,首先一步是在authorization server上註冊App。目前本機除錯時,Django伺服器使用的是本機的8080埠。通過訪問http://localhost:8080/o/applications,來進行App的註冊。o/applications頁面如下圖所示(localhost可更換為本機區域網ip為192.168.123.96)。


其中RetrofitOAuth是我已經註冊好的一個App,也是用於本次除錯使用的App。這裡RetrofitOAuth只是一個名字,可在真正開發App之前就確定好。第一次登入o/applications頁面時需要用使用者名稱和密碼登入authorization server。

我們可以點選"New Application"來註冊一個新的App。註冊時要將redirect uri填寫清楚,本工作的redirect uri為huyaoyu://callback。註冊好的App資訊如下圖所示。


此時將client id,client secret複製出來待用。

Android App


獲取access token流程

在Android OS上,App獲取access token的流程如下圖所示。


(1)App發出瀏覽authorization server的Intent,目標為獲取authorization code。採用GET方法,並提供client ID,redirect uri和response type。URL為(baseurl即為http://192.168.123.96:8080/)

baseurl/o/authorize/?client_id=your_client_id&redirect_uri=huyaoyu://callback&response_type=code

(2)Android OS處理Intent,提示使用者需選擇一個瀏覽器處理該Intent。

(3)瀏覽器通過GET請求authorization server的頁面。

(4)authorization server返回響應。

(5)使用者登入和授權。

(6)authorization server將redirect uri和authorization code傳送給Android OS。

(7)Android OS根據redirect uri的scheme,將Intent傳送給App處理。

(8)App onResume(),檢測uri的內容中是否正確包含了authorization code。若正確得到了authorization code則利用POST方法,將client ID,client secret和authorization code傳送給authorization server,請求返回access token。URL為(baseurl即為http://192.168.123.96:8080/)

baseurl/o/token/

必須要有最後的斜槓

(9)authorization server檢測請求的合法性,請求合法時返回access token和refresh token等資訊給App。

AccessToken類和Retrofit

關於使用Retrofit通過OAuth2獲取GitHub賬戶資訊的例項,可以參考我的另一篇部落格

最終的access token將通過一個Retrofit的Call物件進行獲取,這裡需要定義一個JAVA class以表達和抽象Call物件獲取到的資料。本工作定義了一個AccessToken類,該類的成員變數僅包含oauth toolkit在成功返回access token時的response中的key-value對。這些key包括:access_token, refresh_token, token_type, expires_in和scope。並順帶定義了所有成員變數的Getter函式。

使用Retrofit完成上述的Call動作,需要一個Retrofit client。本工作中定義了一個稱為HuyaoyuClient的類。並定義了一個成員函式getAccessToken(),這個函式利用grant type, client ID, client_secret, code (authorization code) 和 redirect uri從authorization server獲取資訊,最重要的部分是code。這個code即為authorization code。正確獲取到authorization code後,需要及時訪問authorization server以獲取access token否則authorization code 將會過期。

例項

以下通過一個Android App例項進行說明,這個App將有一個極簡單的layout。在這個layout上有一個Button,點選該Button後將開始執行前面描述過的“獲取access token流程”。成功獲取到access token之後,將會通過幾個TextView顯示獲取到的資訊。並且App的實時狀態也會通過Toast顯示。

例項中的部分資訊參考了


在app設定(build.gradle)中增加對Retrofit的依賴

dependencies {
    compile 'com.squareup.retrofit2:retrofit:2.3.0'
    compile 'com.squareup.retrofit2:converter-gson:2.3.0'
}

上述依賴設定省略了其他依賴項。

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.huyaoyu.retrofitoauth.MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Get access token"
        android:textSize="30sp"
        android:onClick="onClickGetAccessToken"
        android:layout_centerHorizontal="true" />

    <TextView
        android:id="@+id/textViewAccessToken"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/button"
        android:textSize="25sp"/>

    <TextView
        android:id="@+id/textViewTokenType"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/textViewAccessToken"
        android:textSize="25sp"/>

    <TextView
        android:id="@+id/textViewExpiresIn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/textViewTokenType"
        android:textSize="25sp"/>

    <TextView
        android:id="@+id/textViewRefreshToken"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/textViewExpiresIn"
        android:textSize="25sp"/>

    <TextView
        android:id="@+id/textViewScope"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/textViewRefreshToken"
        android:textSize="25sp"/>

</RelativeLayout>

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.huyaoyu.retrofitoauth">

    <uses-permission android:name="android.permission.INTERNET" />
   
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data android:host="callback"
                    android:scheme="huyaoyu"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

AccessToken.java

package com.huyaoyu.retrofitoauth;

import com.google.gson.annotations.SerializedName;

/**
 * Created by yaoyu on 3/3/18.
 */

class AccessToken {

    @SerializedName("access_token")
    private String accessToken;

    @SerializedName("refresh_token")
    private String refreshToken;

    @SerializedName("token_type")
    private String tokenType;

    @SerializedName("expires_in")
    private String expiresIn;

    @SerializedName("scope")
    private String scope;

    public String getAccessToken() {

        return accessToken;
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public String getTokenType() {

        return tokenType;
    }

    public String getExpiresIn() {
        return expiresIn;
    }

    public String getScope() {
        return scope;
    }
}

HuyaoyuClient.java

package com.huyaoyu.retrofitoauth;

import retrofit2.Call;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.Headers;
import retrofit2.http.POST;

/**
 * Created by yaoyu on 3/2/18.
 */

public interface HuyaoyuClient {

    @Headers({
            "Content-Type': 'application/x-www-form-urlencoded",
            "Accept: application/json"
    })
    @POST("o/token/")
    @FormUrlEncoded
    Call<AccessToken> getAccessToken(
            @Field("grant_type") String grantType,
            @Field("client_id") String clientID,
            @Field("client_secret") String clientSecret,
            @Field("code") String code,
            @Field("redirect_uri") String redirectUri
    );
}

MainActivity.java

注意authorization server本機除錯時不能使用"localhost",而需要使用真實的區域網ip地址,這裡本機的區域網ip地址為192.168.123.96。

package com.huyaoyu.retrofitoauth;

import android.content.Intent;
import android.net.Uri;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;

public class MainActivity extends AppCompatActivity {

    private String clientId     = "Your client ID";
    private String clientSecret = "your client secret";
    private String redirectUri  = "huyaoyu://callback";

    private String localHost = "http://192.168.123.96:8080/";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    protected void onResume() {
        super.onResume();

        Uri uri = getIntent().getData();

        if (uri != null && uri.toString().startsWith(redirectUri)) {
            String code = uri.getQueryParameter("code");

            Retrofit.Builder builder = new Retrofit.Builder()
                    .baseUrl(localHost)
                    .addConverterFactory(GsonConverterFactory.create());

            Retrofit retrofit = builder.build();

            HuyaoyuClient client = retrofit.create(HuyaoyuClient.class);

            Call<AccessToken> accessTokenCall =
                    client.getAccessToken("authorization_code", clientId, clientSecret, code, redirectUri);

            accessTokenCall.enqueue(new Callback<AccessToken>() {
                @Override
                public void onResponse(Call<AccessToken> call, Response<AccessToken> response) {
                    String accessToken = response.body().getAccessToken();

                    TextView textView = findViewById(R.id.textViewAccessToken);
                    textView.setText("Access token: " + accessToken);

                    textView = findViewById(R.id.textViewTokenType);
                    textView.setText("Token type: " + response.body().getTokenType());

                    textView = findViewById(R.id.textViewExpiresIn);
                    textView.setText("Expires in: " + response.body().getExpiresIn());

                    textView = findViewById(R.id.textViewRefreshToken);
                    textView.setText("Refresh token: " + response.body().getRefreshToken());

                    textView = findViewById(R.id.textViewScope);
                    textView.setText("Scope: " + response.body().getScope());

                    Toast.makeText(MainActivity.this,
                            "Access token obtained!",
                            Toast.LENGTH_SHORT
                    ).show();
                }

                @Override
                public void onFailure(Call<AccessToken> call, Throwable t) {
                    Toast.makeText(MainActivity.this, "No!", Toast.LENGTH_SHORT).show();
                }
            });

            Toast.makeText(MainActivity.this, "Yeah!", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(MainActivity.this, "Null!", Toast.LENGTH_SHORT).show();
        }
    }

    public void onClickGetAccessToken(View view) {
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(
                localHost + "o/authorize" + "?response_type=code&client_id=" + clientId
                        + "&redirect_uri=" + redirectUri
        ));

        startActivity(intent);
    }
}

除錯

編譯環境選取的是Android API24,並虛擬了一個Android 7.0 帶有Play Store的虛擬機器。虛擬機器上執行Chrome瀏覽器。

除錯開始後進入App,點選Button,彈出Chrome,提示登入一個有效註冊於authorization server上的使用者。登入後將會看到如下畫面。


點選“Authorize”以向App傳送authorization code。之後無需使用者再幹預,App將自動獲取access token。成功獲取access token後將看到如下畫面。