GoでOAuth2.0を使った認可を行う

Goで42intraのCLIツールを作るためにはintraにおけるユーザーログインが必要になる。 42のAPIはOAuth2.0を使っている。

OAuth2.0の流れ

RFC 6749

+--------+                               +---------------+
|        |--(A)- Authorization Request ->|   Resource    |
|        |                               |     Owner     |
|        |<-(B)-- Authorization Grant ---|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(C)-- Authorization Grant -->| Authorization |
| Client |                               |     Server    |
|        |<-(D)----- Access Token -------|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(E)----- Access Token ------>|    Resource   |
|        |                               |     Server    |
|        |<-(F)--- Protected Resource ---|               |
+--------+                               +---------------+
  • resource owner

    • An entity capable of granting access to a protected resource. When the resource owner is a person, it is referred to as an end-user.
    • 保護されたリソースへのアクセスを許可することができるエンティティ。 リソースの所有者が人である場合、それはエンドユーザーと呼ばれる。
  • resource server

    • The server hosting the protected resources, capable of accepting and responding to protected resource requests using access tokens.
    • 保護されたリソースをホストするサーバで、アクセストークンを使用して保護されたリソースの要求を受け入れ、応答することができる。
  • client

    • An application making protected resource requests on behalf of the resource owner and with its authorization. The term “client” does not imply any particular implementation characteristics (e.g., whether the application executes on a server, a desktop, or other devices).
    • リソースの所有者に代わって、その承認を得て、保護されたリソースの要求を行うアプリケーションのこと。 クライアント」という用語は、特定の実装特性(アプリケーションがサーバ、デスクトップ、またはその他のデバイス上で実行されるかどうかなど)を意味するものではない。
  • authorization server

    • The server issuing access tokens to the client after successfully authenticating the resource owner and obtaining authorization.
    • サーバーは、リソースの所有者の認証と認可の取得に成功した後、クライアントにアクセストークンを発行します。

RFCからの引用とDeepLに突っ込んだ日本語。

エンドユーザーが持つ資源(個人情報など)にクライアント(作りたいアプリケーションなど)からアクセスできるように認可するための仕組みがOAuth。 図の流れは抽象フローだから、実際の動きは以下の図が近い。

+----------+
| Resource |
|   Owner  |
|          |
+----------+
     ^
     |
    (B)
+----|-----+          Client Identifier      +---------------+
|         -+----(A)-- & Redirection URI ---->|               |
|  User-   |                                 | Authorization |
|  Agent  -+----(B)-- User authenticates --->|     Server    |
|          |                                 |               |
|         -+----(C)-- Authorization Code ---<|               |
+-|----|---+                                 +---------------+
  |    |                                         ^      v
 (A)  (C)                                        |      |
  |    |                                         |      |
  ^    v                                         |      |
+---------+                                      |      |
|         |>---(D)-- Authorization Code ---------'      |
|  Client |          & Redirection URI                  |
|         |                                             |
|         |<---(E)----- Access Token -------------------'
+---------+       (w/ Optional Refresh Token)

User-AgentがよくGoogleのログイン画面として出てくるやつ。 今回はこの仕組みを使ってGoogleと42intraにログインしてみる。

Goの実装

とりあえずコードはこんな感じ。

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"github.com/spf13/viper"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	oauthapi "google.golang.org/api/oauth2/v2"
)

var (
	confGoogle *oauth2.Config
	confIntra  *oauth2.Config
)

func main() {
	if err := setConfig(); err != nil {
		return
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/login/google", GoogleLoginHandler)
	mux.HandleFunc("/login/google/redirect", GoogleLoginRHandler)
	mux.HandleFunc("/login/intra", IntraLoginHandler)
	mux.HandleFunc("/login/intra/redirect", IntraLoginRHandler)

	log.Println("Server has started")
	fmt.Println("Pleas access: http://localhost:5001/login/google")
	fmt.Println("Pleas access: http://localhost:5001/login/intra")
	http.ListenAndServe(":5001", mux)
}

func setConfig() error {
	viper.AddConfigPath(".")
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
  1 package·main
	if err := viper.ReadInConfig(); err != nil {
		log.Print("[ERROR] viper: ", err)
  1 package·main
		return err
	}

	key := viper.GetStringMapString("google")
	confGoogle = &oauth2.Config{
		ClientID:     key["client_id"],
		ClientSecret: key["client_secret"],
		Scopes:       []string{oauthapi.UserinfoEmailScope},
		Endpoint:     google.Endpoint,
		RedirectURL:  "http://localhost:5001/login/google/redirect",
	}

	key = viper.GetStringMapString("intra")
	confIntra = &oauth2.Config{
		ClientID:     key["client_id"],
		ClientSecret: key["client_secret"],
		Scopes:       []string{"public", "projects", "profile", "elearning", "tig", "forum"},
		Endpoint: oauth2.Endpoint{
			AuthURL:  "https://api.intra.42.fr/oauth/authorize",
			TokenURL: "https://api.intra.42.fr/oauth/token",
		},
		RedirectURL: "http://localhost:5001/login/intra/redirect",
	}

	return nil
}

func GoogleLoginHandler(w http.ResponseWriter, r *http.Request) {
	var url = confGoogle.AuthCodeURL("yourStateUUID", oauth2.AccessTypeOffline)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func GoogleLoginRHandler(w http.ResponseWriter, r *http.Request) {
	code := r.URL.Query()["code"]
	if code == nil || len(code) == 0 {
		fmt.Fprint(w, "Invalid Parameter")
	}
	ctx := context.Background()
	tok, err := confGoogle.Exchange(ctx, code[0])
	if err != nil {
		fmt.Fprintf(w, "OAuth Error:%v", err)
	}
	client := confGoogle.Client(ctx, tok)
	svr, err := oauthapi.New(client)
	ui, err := svr.Userinfo.Get().Do()
	if err != nil {
		fmt.Fprintf(w, "OAuth Error:%v", err)
	} else {
		fmt.Fprintf(w, "Your are logined as : %s", ui.Email)
	}
}

func IntraLoginHandler(w http.ResponseWriter, r *http.Request) {
	var url = confIntra.AuthCodeURL("")
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func IntraLoginRHandler(w http.ResponseWriter, r *http.Request) {
	log.Println("IntraLoginRHandler")
	code := r.URL.Query()["code"]
	if code == nil || len(code) == 0 {
		fmt.Fprint(w, "Invalid Parameter")
	}
	log.Println("Code is valid")
	ctx := context.Background()
	tok, err := confIntra.Exchange(ctx, code[0])
	if err != nil {
		fmt.Fprintf(w, "OAuth Error:%v", err)
	}
	log.Println("Token exchange success")
	client := confIntra.Client(ctx, tok)
	res, err := client.Get("https://api.intra.42.fr/v2/me/projects")
	if err != nil || res.StatusCode != http.StatusOK {
		log.Println("/me/projects failed")
		fmt.Fprintln(w, "Error: ", err)
	} else {
		log.Println("/me/projects SUCCEEDED!!!!!!!!")
		fmt.Fprintln(w, res.Body)
	}
}

config

oauth2ライブラリを使うことで簡単に実装ができる

conf = &oauth2.Config{
	ClientID:     "<your client id>",
	ClientSecret: "<your client secret>",
	Scopes:       []string{"public", "projects", "profile", "elearning", "tig", "forum"},
	Endpoint: oauth2.Endpoint{
		AuthURL:  "https://api.intra.42.fr/oauth/authorize",
		TokenURL: "https://api.intra.42.fr/oauth/token",
	},
	RedirectURL: "http://localhost:5001/login/intra/redirect",
}

oauth2.Config構造体を使う。 それぞれのメンバ変数に必要な値をセットする。 使いたいアカウント認証(Google, Twitter, 42intra)によってこの辺りは適宜書き換える。

GoogleアカウントだとスコープとかEndpointとかが定義されたパッケージがあるからそれを使うと楽。 GitHubとかもある(golang.org/x/oauth2/github)。

ログイン用URL

var url = conf.AuthCodeURL("your State UUID", oauth2.AccessTypeOffline)

AuthCodeURLメソッドを使うとログイン用のURLを生成してくれる。 その時CSRF対策用のUUIDを追加することができる。 不要な場合は空文字列だけを渡せば良い。

セキュリティをわざわざ下げる必要はないけど、テストで一時的に使いたいとかかな。 この辺りはセキュリティが全然わからないから想像。

今回はログイン用のURLを用意(http://localhost:5001/login/<service>)して、アクセスするとAuthCodeURLで生成したアドレスにリダイレクトをするようにした。

ログイン後の処理

ユーザーがログインに成功すると、リダイレクトURLにサーバーからHTTPリクエストが送られてくる。 そのリクエストの中に認証済みのアクセスコードが含まれている。 認可サーバにアクセスコードとともにリクエストを送るとトークンが返ってくる。 返ってきたトークンと一緒にAPIサーバにリクエストを送るとリソースにアクセスできるようになる。

// HTTPリクエストからコードを取り出す
code := r.URL.Query()["code"]
if code == nil || len(code) == 0 {
	fmt.Fprint(w, "Invalid Parameter")
}

// コードとトークンを交換する
ctx := context.Background()
tok, err := confIntra.Exchange(ctx, code[0])
if err != nil {
	fmt.Fprintf(w, "OAuth Error:%v", err)
}

// トークンを自動的に付与するHTTPクライアントを作る
client := confIntra.Client(ctx, tok)

// トークンを自動的につけてHTTPリクエストを送る
res, err := client.Get("https://api.intra.42.fr/v2/me/projects")
if err != nil || res.StatusCode != http.StatusOK {
	log.Println("/me/projects failed")
	fmt.Fprintln(w, "Error: ", err)
} else {
	log.Println("/me/projects SUCCEEDED!!!!!!!!")
	fmt.Fprintln(w, "Logged in!")
}

おわり

OAuthの仕組みをちょっと理解した。 Goのライブラリ便利だな。 curlでがんばらずにライブラリ使っていろいろ試すと良さそう。

参考記事

golangでGoogleとOAuth2.0で彼女を作る