0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Microsoft Azureを利用するデスクトップアプリのデバイス認証

Last updated at Posted at 2017-05-09

概要

Go から Microsoft Azure を利用する場合,アクセストークン使用する.
このアクセストークンの取得には複数の方法が用意されているが,
CLI コマンドでも利用されているデバイスコードを用いた認証方法についてまとめる.

アプリケーションの登録


認証プロセスで必要となるクライアント ID を取得するために,
Azure ポータルにてアプリケーションの登録を行う.

1.png

アプリの登録は,ポータルのセキュリティ + ID カテゴリにある「アプリの登録」から行える.

2.png

今回作成するのは,デスクトップアプリケーションなので,アプリの種類としてネイティブを選ぶ.
リダイレクト URL は文字列として有効な URL であれば適当で良いので,
例えば,http://localhost:18230 などとしておく.

デバイスコードの取得

デバイスコードの取得には,
https://login.microsoftonline.com/common/oauth2/devicecode
にクライアント ID とアクセスを要求するリソースをクエリとして追加し問い合わせることで取得できる.
レスポンスは次のような構造を持つ JSON 形式で取得できる.

type DeviceCode struct {
	UserCode        string `json:"user_code"`
	DeviceCode      string `json:"device_code"`
	VerificationURL string `json:"verification_url"`
	ExpiresIn       string `json:"expires_in"`
	Interval        string `json:"interval"`
	Message         string `json:"message"`
}

次の関数はデバイスコードを取得し上記の構造体として返す.

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"

	"golang.org/x/net/context/ctxhttp"
)


func GetDeviceCode(ctx context.Context, clientID string) (code *DeviceCode, err error) {

	u := fmt.Sprintf(
		"https://login.microsoftonline.com/common/oauth2/devicecode?client_id=%v&resource=%v",
		clientID,
		url.QueryEscape("https://management.core.windows.net/"))

	req, err := http.NewRequest("Get", u, nil)
	if err != nil {
		return
	}
	req.Header.Add("Accept", "application/json")

	res, err := ctxhttp.Do(ctx, nil, req)
	if err != nil {
		return
	}
	defer res.Body.Close()

	code = new(DeviceCode)
	err = json.NewDecoder(res.Body).Decode(&code)
	return

}

アクセストークンの取得

次に,ユーザにこのデバイスコードにある VerificationURL へアクセスしてもらい,
UserCode を入力してもらう必要がある.
このユーザによる認証操作が終了すると,
https://login.microsoftonline.com/common/oauth2/token
からアクセストークンを取得できるようになる.

したがって,上記 URL のポーリングを行いユーザの操作を待つ.
ポーリング間隔は Interval で指定されている秒数を使う.
なお,このリクエストにはクライアント ID とデバイスコードを含める必要があり,
GET ではなく POST で問い合わせる必要がある.

ユーザがアクセスを承認すると下記の構造体からなる JSON オブジェクトが返ってくる.

type Token struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    string `json:"expires_in"`
	ExpiresOn    string `json:"expires_on"`
	Resource     string `json:"resource"`
	Scope        string `json:"scope"`
	RefreshToken string `json:"refresh_token"`
	IDToken      string `json:"id_token"`
}

一方,ユーザが認証を却下した場合は,次の構造体からなる JSON オブジェクトが返ってくる.

type TokenError struct {
	ErrorSummary     string `json:"error"`
	ErrorDescription string `json:"error_description"`
	ErrorCodes       []int  `json:"error_codes"`
	Timestamp        string `json:"timestamp"`
	TraceID          string `json:"trace_id"`
	CorrelationID    string `json:"correlation_id"`
}

// error として使えるように Error() string を定義しておく
func (e *TokenError) Error() string {
	return e.ErrorDescription
}

次の関数はデバイスコードを取得し,ユーザの操作が終わるまでポーリングを行いアクセストークンを取得する.

func AuthorizeDeviceCode(ctx context.Context, clientID string) (token *Token, err error) {

	// デバイスコードの取得
	code, err := GetDeviceCode(ctx, clientID)
	if err != nil {
		return
	}

	// ユーザへ認証 URL へのアクセスと UserCode の入力を促すメッセージを表示
	fmt.Println(code.Message)

	// ExpiresIn はデバイスコードの有効期限(秒)
	expire, err := strconv.Atoi(code.ExpiresIn)
	if err != nil {
		return
	}
	interval, err := strconv.Atoi(code.Interval)
	if err != nil {
		return
	}

	// デバイスコードの有効期限が切れる前に以降の操作をキャンセルさせる
	ctx, cancel := context.WithDeadline(
		ctx, time.Now().Add(time.Duration(expire-30)*time.Second))
	defer cancel()

	// ユーザの操作が終わるまでポーリングする
	var req *http.Request
	var res *http.Response
	for {

		body := fmt.Sprintf(
			"resource=%v&client_id=%v&grant_type=device_code&code=%v",
			url.QueryEscape("https://management.core.windows.net/"),
			clientID,
			code.DeviceCode)
		req, err = http.NewRequest("Post", "https://login.microsoftonline.com/common/oauth2/token", strings.NewReader(body))
		if err != nil {
			return
		}
		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Add("Accept", "application/json")

		res, err = ctxhttp.Do(ctx, nil, req)
		if err != nil {
			break
		}

		if res.StatusCode == 400 {
			// ユーザが却下した場合はエラーを返す
			var autherror TokenError
			err = json.NewDecoder(res.Body).Decode(&autherror)
			res.Body.Close()
			if err != nil {
				break
			}
			if strings.ToLower(autherror.ErrorSummary) != "authorization_pending" {
				break
			}

		} else {
			// ユーザが承認しアクセストークンが返ってきた場合
			token = new(Token)
			err = json.NewDecoder(res.Body).Decode(token)
			res.Body.Close()
			if err != nil {
				return
			}
			break
		}

		select {
		case <-ctx.Done():
			// デバイスコードの有効期限が切れた場合
			// または上流のコンテキストがキャンセルされた場合
			err = ctx.Err()
			break
		case <-time.After(time.Duration(interval) * time.Second):
		}

	}

	return

}

アクセストークンの更新

得られたトークンには,ExpiresInExpiresOn という属性があるように,
有効期限が定められている.
有効期限が切れた場合,RefreshToken を用いて新しいトークンを取得する.

なお,アクセストークンを更新する場合,ユーザのテナント ID (Active Directory のディレクトリ ID) が必要になる.

func RefreshToken(token *Token, clientID, tenantID string) (newToken *Token, err error) {

	request := make(url.Values)
	request.Add("grant_type", "refresh_token")
	request.Add("client_id", clientID)
	request.Add("refresh_token", token.RefreshToken)
	request.Add("resource", "https://management.core.windows.net/")

	res, err := http.PostForm(fmt.Sprintf(tokenEndpoint, tenantID), request)
	if err != nil {
		return
	}
	defer res.Body.Close()

	if res.StatusCode != 200 {
		var info TokenError
		err = json.NewDecoder(res.Body).Decode(&info)
		if err != nil{
			return nil, err
		}
		return nil, &info
	}

	newToken = new(Token)
	err = json.NewDecoder(res.Body).Decode(newToken)
	return

}
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?