🍻

雰囲気でOAuthを使っていたエンジニアがOAuthサーバ(RFC6749)を自作してみる

2022/01/03に公開2

はじめに

Auth屋さんの本やその他有識者のBlogなどを読むことで少しながらOAuthやOIDCの仕組みが理解できてきました。
そんななかで以下の記事が大変勉強になりました。

https://qiita.com/TakahikoKawasaki/items/e508a14ed960347cff11

↑の記事ではRubyで実装されているのですが、これを参考というかほぼ丸コピですがgolangで実装してみたいと思います。

コードは以下にあります。
https://github.com/sat0ken/goauth-server

仕様

OAuthサーバでは認可エンドポイントとトークンエンドポイントを実装する必要があります。
認可とトークンエンドポイントの2つに加えてユーザ認証を行うエンドポイントを作ります。
今回は元記事と同じようにFormに入力したユーザ&パスワードを受け取り確認します。

RFC6749に関する仕様は元記事の2.注意点と同じになるはずです。
「はずです」というのは恥ずかしながらまだ完全な理解に至っておらず今もRFCを読みながら答え合わせ中です。
ぜひ認識違いがあればご指摘ください🙇‍♂️🙇‍♂️🙇‍♂️

あとPKCE(code_challengeとcode_verifier)はサポートしてみました。

実装

今回もnet/httpでhttpサーバを立てて処理を書いていきます。
以下3つのエンドポイントを用意しておきます。

  • /auth 認可エンドポイント
  • /authcheck 認証用
  • /token トークンエンドポイント

認可エンドポイント

認可エンドポイントではリクエストを受け取ったら必須パラメータがURLクエリに含まれているかまず確認をします。
パラメータのチェックの次はその中身を確認します。
クライアントIDは今回登録済みと仮定してハードコードしている値と一致するか、サポートしているレスポンスタイプと一致するか確認しています。

パラメータチェックが終わったら、セッションIDを生成して構造体に保存しておきます。
セッションIDとセットでcode_challengeを保存しておき同じセッションからのトークンリクエストに含まれるcode_verifierを計算して保存されていたcode_challengeと一致するかサーバ側で確認することでなりすましを防ぎます。

最後にクライアントにログイン画面とセッションをCookieにセットして戻したら認可エンドポイントの処理は終わりです。

func auth(w http.ResponseWriter, req *http.Request) {
	query := req.URL.Query()
	requiredParameter := []string{"response_type", "client_id", "redirect_uri"}
	// 必須パラメータのチェック
	for _, v := range requiredParameter {
		if !query.Has(v) {
			log.Printf("%s is missing", v)
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte(fmt.Sprintf("invalid_request. %s is missing", v)))
			return
		}
	}
	// client id の一致確認
	if clientInfo.id != query.Get("client_id") {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("client_id is not match"))
		return
	}
	// レスポンスタイプはいったん認可コードだけをサポート
	if "code" != query.Get("response_type") {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("only support code"))
		return
	}
	sessionId := uuid.New().String()
	// セッションを保存しておく
	session := Session{
		client:                query.Get("client_id"),
		state:                 query.Get("state"),
		scopes:                query.Get("scope"),
		redirectUri:           query.Get("redirect_uri"),
		code_challenge:        query.Get("code_challenge"),
		code_challenge_method: query.Get("code_challenge_method"),
	}
	sessionList[sessionId] = session

	// CookieにセッションIDをセット
	cookie := &http.Cookie{
		Name:  "session",
		Value: sessionId,
	}
	http.SetCookie(w, cookie)

	// ログイン&権限認可の画面を戻す
	if err := templates["login"].Execute(w, struct {
		ClientId string
		Scope    string
	}{
		ClientId: session.client,
		Scope:    session.scopes,
	}); err != nil {
		log.Println(err)
	}
	log.Println("return login page...")

}

認証用エンドポイント

認可エンドポイントから戻されたログイン画面でユーザ&パスワードを入力してボタンを押すとこのエンドポイントにPOSTされます。
ユーザとパスワードがあらかじめサーバ側でハードコードしている値(hoge:password)と一致するかを確認します。

ユーザとパスワードの確認が済んだら、クライアントから送られてきたセッションIDで認可リクエスト時に保存していたセッション情報をロードします。
uuidライブラリで生成した認可コードとともにセッション情報からclientIdscopeを認可コードの情報保持用構造体にセットしておきます。
最後にLocationヘッダーにrideirect_uriと認可コード, stateをセットして戻したら処理は終わりです。

// 認可レスポンスを返す
func authCheck(w http.ResponseWriter, req *http.Request) {

	loginUser := req.FormValue("username")
	password := req.FormValue("password")

	if loginUser != user.name || password != user.password {
		w.Write([]byte("login failed"))
	} else {

		cookie, _ := req.Cookie("session")
		http.SetCookie(w, cookie)
		v, _ := sessionList[cookie.Value]

		authCodeString := uuid.New().String()
		authData := AuthCode{
			user:         loginUser,
			clientId:     v.client,
			scopes:       v.scopes,
			redirect_uri: v.redirectUri,
			expires_at:   time.Now().Unix() + 300,
		}
		// 認可コードを保存
		AuthCodeList[authCodeString] = authData

		log.Printf("auth code accepet : %s\n", authData)

		location := fmt.Sprintf("%s?code=%s&state=%s", v.redirectUri, authCodeString, v.state)
		w.Header().Add("Location", location)
		w.WriteHeader(302)

	}

}

トークンエンドポイント

クライアントは取得した認可コードを使用してトークンエンドポイントにリクエストを送るのでそれに対応する処理を書きます。
まずは必須パラメータがあるかチェックを行いなければエラーを返します。
その後各パラメータの内容チェックを行います。

  • grant_type
    "authorization_code" = 認可コードフローだけサポート
  • code
    認可コードを払い出した時に保存しているのでそれと同じものがあるか、なければエラー
  • client_id
    認可リクエスト時のクライアントと同じ人物か
  • redirect_uri
    認可リクエスト時と同じ値か
  • expires_at
    認可コードの有効期限が切れていないか
  • client__secret
    シークレットがサーバ側ハードコードの値と一致するか
    シークレットはBasic認証でAuthorizationヘッダーにセットする形式もRFC6749で認められていますが、今回の実装ではURLパラメータで送る方をサポートしてます
  • code_verifier
    sh256で計算&base64urlエンコードしたものを認可リクエスト時に送られてセッション情報に保存していたcode_challengeの値と一致するか

ここまでチェックを行い問題がなければトークンを生成してクライアントに返します。
uuidライブラリでトークンを生成&有効期限を計算したら情報保持用の構造体にいったん保存します。
保存後トークン、タイプ、有効期限をjsonデータにしてクライアントに戻せば処理は終わりです。

// トークンを発行するエンドポイント
func token(w http.ResponseWriter, req *http.Request) {

	cookie, _ := req.Cookie("session")
	req.ParseForm()
	query := req.Form

	requiredParameter := []string{"grant_type", "code", "client_id", "redirect_uri"}
	// 必須パラメータのチェック
	for _, v := range requiredParameter {
		if !query.Has(v) {
			log.Printf("%s is missing", v)
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte(fmt.Sprintf("invalid_request. %s is missing\n", v)))
			return
		}
	}

	// 認可コードフローだけサポート
	if "authorization_code" != query.Get("grant_type") {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("invalid_request. not support type.\n")))
	}

	// 保存していた認可コードのデータを取得。なければエラーを返す
	v, ok := AuthCodeList[query.Get("code")]
	if !ok {
		log.Println("auth code isn't exist")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("no authrization code")))
	}

	// 認可リクエスト時のクライアントIDと比較
	if v.clientId != query.Get("client_id") {
		log.Println("client_id not match")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("invalid_request. client_id not match.\n")))
	}

	// 認可リクエスト時のリダイレクトURIと比較
	if v.redirect_uri != query.Get("redirect_uri") {
		log.Println("redirect_uri not match")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("invalid_request. redirect_uri not match.\n")))
	}

	// 認可コードの有効期限を確認
	if v.expires_at < time.Now().Unix() {
		log.Println("authcode expire")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("invalid_request. auth code time limit is expire.\n")))
	}

	// clientシークレットの確認
	if clientInfo.secret != query.Get("client_secret") {
		log.Println("client_secret is not match.")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("invalid_request. client_secret is not match.\n")))
	}

	// PKCEのチェック
	// clientから送られてきたverifyをsh256で計算&base64urlエンコードしてから
	// 認可リクエスト時に送られてきてセッションに保存しておいたchallengeと一致するか確認
	session := sessionList[cookie.Value]
	if session.code_challenge != base64URLEncode(query.Get("code_verifier")) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("PKCE check is err..."))
	}

	tokenString := uuid.New().String()
	expireTime := time.Now().Unix() + ACCESS_TOKEN_DURATION

	tokenInfo := TokenCode{
		user:       v.user,
		clientId:   v.clientId,
		scopes:     v.scopes,
		expires_at: expireTime,
	}
	TokenCodeList[tokenString] = tokenInfo
	// 認可コードを削除
	delete(AuthCodeList, query.Get("code"))

	tokenResp := TokenResponse{
		AccessToken: tokenString,
		TokenType:   "Bearer",
		ExpiresIn:   expireTime,
	}
	resp, err := json.Marshal(tokenResp)
	if err != nil {
		log.Println("json marshal err")
	}

	log.Printf("token ok to client %s, token is %s", v.clientId, string(resp))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(resp)

}

テスト

コードが書けたら期待したOAuthのフローになっていてトークンが取得できるか確認をします。
まずgo run . でOAuthサーバを起動させておきます。

OAuth debuggerというサイトがあるのでこれをクライアント側として試してみます。

https://oauthdebugger.com/

以下写真のように値を入れたら下の方にスクロールしてSEND REQUESTボタンを押します。

ログイン画面にリダイレクトされるのでユーザとパスワードを入力してボタンを押します。
ここではhogepasswordを入れます。

サーバがトークンを払い出してOAuth debuggerに戻ります。
画面にSuccessと出ていればOKです。

おわりに

こういういわゆる「車輪の再発明」を実際やってみると再発明をするなかでこれまで知らなかったことを知ることができたので冬休みにやってみてよかったです。😊😊😊

Discussion

ritouritou

仕様の理解が進んだら、他のgrant_typeもサポートしたり、クライアント認証の種類を増やしたときにどう実装が変わるかなどを考えたり、他の人のサーバー実装を見てみるとよいと思います。
自分ならクライアント認証が先かなとか、keycloakのようにより汎用的なソフトウェアにしようと思ったらどの判定を設定で持たせようかなとか考えられるようになると次のフェーズにはいれると思います。

satokensatoken

コメントありがとうございます。
できることはたくさんあると思いますのでちょっとずつ機能追加していきたいと思います🙇‍♂️🙇‍♂️