Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

GitHub Appsを利用したデバイスフローによるユーザー確認

はじめに

エンジニア2年目のTKDSです! 今回はGitHubのデバイスフローを利用したユーザー認証の方法についてご紹介します。 デバイスフローはブラウザに直接アクセスできないCLIツールでもGitHub側に認証を依頼できる機能です。 今回はデバイスフローによる認証を経て発行されるアクセストークンを使って、認証・認可を要求したユーザー名を取得し、ユーザー確認に利用するところまでやっていきます。

GitHub Appsによるデバイスフロー

認証リクエスト時に返ってくるURL・ユーザーコードを使用し、ブラウザで認証を行うとアプリケーション側でアクセストークンの発行を行えます。
アクセストークンのリクエストには初回の認証リクエスト時に返ってくるデバイスコードが必要なため、アクセストークンのリクエストを行うエンドポイントを単純に叩いてアクセストークンを横取りされる危険性は低いと考えられます。
この方法を利用するメリットとしては、
- ユーザーはURLを開いて、ユーザーコードを入力するだけでいい
- ユーザーの認証機能を直接提供せずに、GitHubに認証を依頼できる
- ユーザーに認証させたあと取得できるユーザートークンを使いユーザー情報を取得できるので、認証依頼時点でユーザーの情報を知ってる必要がなく、エンドポイントから返ってきたユーザートークンを使ってはじめてユーザーの特定ができる
- GitHub Appsで権限を絞れば、ほとんどの情報取得をシャットアウトできる→ユーザートークンはもちろんもらさない前提で安全性も高い

等があります。 ユーザー側の利便性、アプリ自体はURLとユーザーコードを提供するだけの利便性がいいと思って今回採用してみました。
では次に実装例を示していきます。

実際にやってみる

今回やりたかったのは、ユーザー認証と認証したユーザーのアカウント名の取得です。
まず、今回の処理の流れを以下に示します。

ユーザーがまずアプリを通して、デバイスフローをGitHubに依頼します。
そして、返ってきた値のうち、URLとuser_codeをユーザーに提示します。
ユーザーが認証するまでの間、アプリ側はGitHubトークン払い出しのエンドポイントをたたきつづけます。
ユーザーは表示されたURLにアクセスし、ログイン後、アクセストークンの入力・権限の認可を行います。
ユーザーによる認可が完了した以降で、アクセストークン払い出しURLにアクセスすると、ユーザーコードを使って認証・認可をしたユーザーに紐付いたアクセストークンを取得できます。
この時点でアプリはユーザーが誰かわからないので、アクセストークンを使ってユーザーの情報を取得します。   最終的にレスポンスのloginキーに含まれてるユーザー名を確認することで、どのユーザーがアクセストークンを払い出したかわかります。
では、実際に準備していきます。

GitHub Appsの準備

今回のデバイスフロー認証にはGitHub Appが必要なため用意します。
New GitHub AppでGitHub Appsの作成をしてください。

設定画面に遷移するので、Enable Device Flowにチェックをいれてください。

次に、GitHub Appによって発行されたトークンになんの操作を許すか設定します。 今回は個人のメールアドレスだけですが、ここで設定を変えれば、所属してるオーガナイゼーションの取得なども可能になります。
今回は一番弱い権限かつ取得しても問題なさそうな情報にしたいので、Email adressesの項目をRead-Onlyで設定します。

これでGitHub Appの設定は完了です。
client_idを控えておいてください、あとで使います。

アプリの準備

CLIアプリを準備していきます。

CLIアプリ側でやりたいことは、デバイスフローのリクエスト、ユーザーへのURLとuser_codeの提示、アクセストークン払い出しのポーリング、取得したアクセストークンによるユーザー情報の取得です。 Goによるコードを書きました。 サードパーティのライブラリ使ってないため、そのまま動くと思います。 今回は先にgo mod initしてしまっていたので、go.modがある状態前提です。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"
)

const (
    // clientID        = "" // GitHub AppのClient ID
    deviceAuthURL   = "https://github.com/login/device/code"
    accessTokenURL  = "https://github.com/login/oauth/access_token"
    githubUserURL   = "https://api.github.com/user"
    pollingInterval = 5 * time.Second // intervalより長くしない、とりあえず5秒
)

// デバイスコード
type deviceCodeResponse struct {
    DeviceCode      string `json:"device_code"`
    UserCode        string `json:"user_code"`
    VerificationURI string `json:"verification_uri"`
    ExpiresIn       int    `json:"expires_in"`
    Interval        int    `json:"interval"`
}

// アクセストークン
type accessTokenResponse struct {
    AccessToken string `json:"access_token"`
    TokenType   string `json:"token_type"`
    Scope       string `json:"scope"`
    Error       string `json:"error,omitempty"`
}

var clientID string

func main() {
    clientID = os.Getenv("CLIENT_ID")

    // 1. デバイスコードを取得
    deviceCodeRes, err := requestDeviceFlow()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("verification_uri: %s\n", deviceCodeRes.VerificationURI)
    fmt.Printf("user_code: %s\n", deviceCodeRes.UserCode)

    // 2. アクセストークンを取得(ポーリング)
    timeout := 3 * time.Minute
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    var accessTokenResponse accessTokenResponse
loop:
    for {
        select {
        case <-ctx.Done():
        default:
            accessTokenRes, err := requestAccessToken(deviceCodeRes.DeviceCode)
            if err != nil {
                log.Fatal(err)
            }
            if accessTokenRes.AccessToken != "" {
                accessTokenResponse = *accessTokenRes
                break loop
            }

            switch accessTokenRes.Error {
            case "authorization_pending":
                fmt.Println("ユーザー認証待ち")
            case "slow_down":
                fmt.Println("ポーリング感覚短すぎ")
            case "access_denied":
                log.Fatal("認証失敗")
            case "expired_token":
                log.Fatal("デバイスコードの有効期限が切れた")
            default:
                log.Fatal(accessTokenResponse.Error)
            }
            // time.Sleep(pollingInterval)
        }
        // time.Sleep(time.Duration(deviceCodeRes.Interval) * time.Second)
        time.Sleep(pollingInterval)
    }

    fmt.Println(accessTokenResponse)

    userInfo, err := getGitHubUserInfo(accessTokenResponse.AccessToken)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(userInfo)
}

func requestDeviceFlow() (*deviceCodeResponse, error) {
    client := http.Client{}

    sendData := url.Values{}
    sendData.Set("client_id", clientID)
    sendData.Set("scope", "repo,user")

    req, err := http.NewRequest("POST", deviceAuthURL, strings.NewReader(sendData.Encode()))
    if err != nil {
        return nil, err
    }

    req.Header.Set("Accept", "application/json")
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    dCodeResp, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer dCodeResp.Body.Close()

    var deviceCodeRes deviceCodeResponse
    err = json.NewDecoder(dCodeResp.Body).Decode(&deviceCodeRes)
    if err != nil {
        return nil, err
    }
    return &deviceCodeRes, nil
}

func requestAccessToken(deviceCode string) (*accessTokenResponse, error) {
    client := &http.Client{}

    data := url.Values{}
    data.Set("client_id", clientID)
    data.Set("device_code", deviceCode)
    data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")

    req, err := http.NewRequest("POST", accessTokenURL, strings.NewReader(data.Encode()))
    if err != nil {
        return nil, err
    }

    req.Header.Set("Accept", "application/json")
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    tokenResp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer tokenResp.Body.Close()

    var tokenRes accessTokenResponse
    err = json.NewDecoder(tokenResp.Body).Decode(&tokenRes)
    if err != nil {
        return nil, err
    }

    return &tokenRes, nil
}

func getGitHubUserInfo(accessToken string) (string, error) {
    req, err := http.NewRequest("GET", githubUserURL, nil)
    if err != nil {
        return "", err
    }
    req.Header.Set("Authorization", "Bearer "+accessToken)
    req.Header.Set("Accept", "application/vnd.github.v3+json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    return string(body), nil
}

実行

漏れたら困る値もあるのでぼかしつつ実際の流れを載せていきます。

1 . アプリ起動
環境変数からclient_idを取得するようにしてあるので、コマンドラインで指定して起動します。 client_idには、先ほどGitHub Appを作ったときに控えておいたclient_idを指定してください。

CLIENT_ID="<控えておいたclient_id>" go run main.go 
verification_uri: https://github.com/login/device
user_code: xxxx-xxxx

2 . ブラウザでユーザーコードを入力

表示されたURLにアクセスし、user_codeを入力してください。

ユーザーコードの認証が成功すると、権限委譲の画面に移行するので、緑のボタンを押してください。

成功すると以下画面が表示されます。
これでユーザー側操作は完了です。

3 . アクセストークンの取得とユーザー情報の取得

アプリ側は2の間ずっとトークン払い出しのエンドポイントをポーリングしています。
これには事前に払い出したclient_idと認証リクエスト時にuser_codeと一緒に返ってくるdevice_codeを使用します。
取得できたアクセストークンをもとにユーザー情報を取得します。
ここで取得したユーザー情報をもとに、アクセスしていいユーザーなのか判別などします。
※ここの画面のスクショはアクセストークンやユーザー情報が出てしまっているので割愛します。

以上で、デバイスフローによるユーザー認証ができました。

まとめ

今回はデバイスフローを利用して、 ユーザー認証を行いました。 今回用意したアプリではclient_idをユーザー側が入力しましたが、サーバー側で行うようにすることでより堅牢にユーザー認証できるのではないかと考えられます。
GitHub側に認証を任せて簡単にユーザー認証ができるのは非常に便利です。
ここまで読んでいただきありがとうございました!

参考文献

Copyright © RAKUS Co., Ltd. All rights reserved.