😍

OAuth 2.0 の認可サーバを自作してみたった

2024/02/04に公開

TL;DR

  • GoでOAuth2.0(RFC6749)の認可サーバとリソースサーバを自作します

はじめに

どうも、アニメマスターです!

「普通に生きていたら、ふと思い立ったかのように、認可サーバを自作したくなった青年がいます」
「そう、私です」

ということで、今回はOAuth 2.0の仕様を満たす認可サーバを自作しながら、アクセストークンが発行されるフローを実際に構築していきます。

概観

01-architecture
今回構築する範囲は認可サーバ(Authorization Server)とリソースサーバ(Resource Server)とDBです。
開発環境での疎通確認をメインとするので、これらをdocker-composeで構築していきます。

ソースコードは以下にありますので、適宜参照ください。

https://github.com/miyuki-starmiya/go-oauth2-server

環境

  • 認可サーバ
    • Language: Go1.21
    • Web Server: net/http
  • リソースサーバ
    • Language: Go1.21
    • Web Server: net/http
  • DB
    • MongoDB latest

仕様

RFC6749に準拠して、現時点で満たしている仕様は以下の通りです(REQUIREDなものもOPTIONALなものも混在させて記述しています)

  • 現時点で満たしている仕様
    • 4.1.1. Authorization Request
      • response_type
      • client_id
      • redirect_uri
      • state
    • 4.1.2 Authorization Response
    • 4.1.3 Access Token Request
    • 4.1.4 Access Token Response
  • 現時点で満たしていない仕様
      1. Client Registration
    • 4.1.1. Authorization Request
      • scope
    • 5.1. Successful Response
      • refresh_token
    • 5.2. Error Response

1. 認可サーバの構築

今回は以下の認可フローを構築していきます。
02-authorization-flow
Ref: https://datatracker.ietf.org/doc/html/rfc6749#autoid-35

この認可フローで認可サーバが実施することは大きく2つあり、1つが認可エンドポイント(/authorization)でAuthorization Codeを付与することと、もう1つがトークンエンドポイント(/token)でAccess Tokenを付与することです。

それでは認可サーバを構築していきましょう。
/auth のディレクトリ構造は以下の様になります。

.
├── Dockerfile
├── cmd
│   └── encode.go
├── errors
│   ├── error.go
│   └── response.go
├── generate
│   ├── authorize.go
│   └── token.go
├── go.mod
├── go.sum
├── handler
│   ├── authorizeHandler.go
│   └── tokenHandler.go
├── main.go
└── util
    └── encode.go

それでは中身を確認していきます。
まず、main.goです。

main.go
main.go
package main

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

	_ "github.com/joho/godotenv/autoload"

	"github.com/miyuki-starmiya/go-oauth2-server/auth/handler"
	"github.com/miyuki-starmiya/go-oauth2-server/db/store"
)

func main() {
	db, err := store.NewDatabase()
	if err != nil {
		log.Printf("Error: %v\n", err)
		return
	}

	ah := handler.NewAuthorizeHandler(
		store.NewCodeStore(db),
	)
	th := handler.NewTokenHandler(
		store.NewCodeStore(db),
		store.NewTokenStore(db),
	)

	http.HandleFunc("/authorize", ah.HandleAuthorizeRequest)
	http.HandleFunc("/token", th.HandleTokenRequest)

	port := "9001"
	host := "0.0.0.0"
	log.Printf("listen start: %s:%s\n", host, port)
	http.ListenAndServe(fmt.Sprintf("%s:%s", host, port), nil)
}

まずはMongoDBのdbを作成して、それをハンドラのコンストラクタに渡す形でハンドラを作成し、それをルーティングしています。
それぞれ認可エンドポイントとトークンエンドポイント用に作成して、サーバを起動しています。
サーバを起動する時に、ホストはlocalhostではなくて、Dockerで起動するので0.0.0.0としています。

1-1. 認可エンドポイント(/authorize)

それでは認可エンドポイントに渡すハンドラを実装していきます。

認可エンドポイントのハンドラ
authorizeHandler.go
package handler

import (
	"log"
	"net/http"
	"os"

	"github.com/miyuki-starmiya/go-oauth2-server/auth/generate"
	"github.com/miyuki-starmiya/go-oauth2-server/db/model"
	"github.com/miyuki-starmiya/go-oauth2-server/db/store"
)

func NewAuthorizeHandler(cs *store.CodeStore) *AuthorizeHandler {
	return &AuthorizeHandler{
		CodeStore: cs,
	}
}

type AuthorizeHandler struct {
	CodeStore *store.CodeStore
}

func (ah *AuthorizeHandler) HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) {
	if !validateAuthorizeRequest(r) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	clientId := r.URL.Query().Get("client_id")
	redirect_uri := r.URL.Query().Get("redirect_uri")
	state := r.URL.Query().Get("state")
	code, _ := generate.NewAuthorizeGenerate().Token(r.Context(), os.Getenv("CLIENT_ID"))

	// store the code object
	authorizationData := &model.AuthorizationData{
		ClientID:          clientId,
		RedirectURI:       redirect_uri,
		AuthorizationCode: code,
	}
	if err := ah.CodeStore.CreateData(authorizationData); err != nil {
		log.Printf("Error: %v\n", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// redirect
	redirectURL := os.Getenv("REDIRECT_URI") + "?code=" + code + "&state=" + state
	http.Redirect(w, r, redirectURL, http.StatusFound)
}

func validateAuthorizeRequest(r *http.Request) bool {
	if r.Method != "GET" {
		log.Println("request method must be GET")
		return false
	}
	if r.URL.Query().Get("response_type") != "code" {
		log.Println("response_type must be code")
		return false
	}
	if r.URL.Query().Get("client_id") != os.Getenv("CLIENT_ID") {
		log.Println("client_id is wrong")
		return false
	}
	if r.URL.Query().Get("redirect_uri") != os.Getenv("REDIRECT_URI") {
		log.Println("redirect_uri is wrong")
		return false
	}
	if r.URL.Query().Get("state") == "" {
		log.Println("state is empty")
		return false
	}

	return true
}

まずコンストラクタがストアを受け取ります。
ストアではDBの接続を実施していますが、ストアの実装は一番最後に行うので、ここではDBに対してfindやinsertができるものくらいに思っておいてください。

HandleAuthorizeRequestメソッドではまずRequestを検証します。
RFC6749で定められているresponse_type, client_id, redirect_uri, stateをバリデーションしています。
本来はクライアントの情報(client_id, redirect_uri)は別途DBに格納して、DBの値と照合させる必要がありますが、現時点ではクライアントの登録はサポートしていないので、便宜的に環境変数で設定した一意のIDと照合させています。

Requestの値が正常であれば、次に認可コード(Authorization Code)を付与し、付与したものをDBに格納しています。

最後に、クライアントに対して認可コードとstateをパラメータとして追加してリダイレクトさせます。

クライアントからの検証はcurlでもPostmanでもなんでも良いですが、Postmanだとすれば以下の様に返ってくることが確認できます。
ここで得られる認可コード(code)は次のトークンエンドポイントで利用するので記録しておいてください。

$ curl http://localhost:9001/authorize?response_type=code&redirect_uri=http://example.com/&state=xyz&client_id=123456

03-redirect-response

1-2. トークンエンドポイント(/token)

次にトークンエンドポイントに渡すハンドラを実装していきます。

トークンエンドポイントのハンドラ
tokenHandler.go
package handler

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/miyuki-starmiya/go-oauth2-server/auth/generate"
	"github.com/miyuki-starmiya/go-oauth2-server/auth/util"
	"github.com/miyuki-starmiya/go-oauth2-server/db/model"
	"github.com/miyuki-starmiya/go-oauth2-server/db/store"
)

func NewTokenHandler(cs *store.CodeStore, ts *store.TokenStore) *TokenHandler {
	return &TokenHandler{
		CodeStore:  cs,
		TokenStore: ts,
	}
}

type TokenHandler struct {
	CodeStore  *store.CodeStore
	TokenStore *store.TokenStore
}

func (th *TokenHandler) HandleTokenRequest(w http.ResponseWriter, r *http.Request) {
	if !th.validateTokenRequest(r) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	clientId, _, _ := getClientData(r)
	access, refresh, _ := generate.NewAccessGenerate().Token(r.Context(), clientId, false)
	tokenType := "Bearer"
	expiresIn := 3600

	// store the token object
	tokenData := &model.TokenData{
		ClientID:     clientId,
		AccessToken:  access,
		ExpiresIn:    expiresIn,
		RefreshToken: refresh,
		IssuedAt:     time.Now(),
		TokenType:    tokenType,
	}
	if err := th.TokenStore.CreateData(tokenData); err != nil {
		log.Printf("Error: %v\n", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"access_token":  access,
		"token_type":    tokenType,
		"expires_in":    expiresIn,
		"refresh_token": refresh,
	})
	log.Println("access token generated successfully")
}

func (th *TokenHandler) validateTokenRequest(r *http.Request) bool {
	// Check Request Parameters
	if r.Method != "POST" {
		log.Println("request method must be POST")
		return false
	}
	if r.URL.Query().Get("grant_type") != "authorization_code" {
		log.Println("grant_type must be authorization_code")
		return false
	}
	if r.URL.Query().Get("redirect_uri") != os.Getenv("REDIRECT_URI") {
		log.Println("redirect_uri is wrong")
		return false
	}

	// Check Basic Authentication Header
	clientId, clientSecret, err := getClientData(r)
	if err != nil {
		log.Printf("Error: %v\n", err)
		return false
	}
	if _, err := th.CodeStore.GetData(clientId, r.URL.Query().Get("code")); err != nil {
		log.Printf("Error: %v\n", err)
		return false
	}
	if clientSecret != os.Getenv("CLIENT_SECRET") {
		log.Println("client secret is wrong")
		return false
	}

	return true
}

func getClientData(r *http.Request) (string, string, error) {
	authorizationHeader, err := util.RetrieveAuthorizationHeader(r)
	if err != nil {
		return "", "", err
	}
	clientId, clientSecret, err := util.DecodeClientBase64(authorizationHeader)
	if err != nil {
		return "", "", err
	}

	return clientId, clientSecret, nil
}

トークンエンドポイントでもまずはRequestを検証していきます。
注意する必要があるのはトークンエンドポイントはclient_idとclient_secretをbase64エンコードしたものが、Basic認証として送られてくるので、Authorization Headerを一度base64デコードして、client_id, client_secretを取得している点です。
あと、認可コード(code)が実際にDBに格納されているものと同値かclient_idとセットで照合しています。

照合が完了したら、アクセストークンを付与しつつ、DBにも格納します。
アクセストークンはJWTを使うケースが多いと思いますが、現時点ではMD5ハッシュ化したものをURLエンコードしたものを生成しています。
最後に、トークンの失効期間等を追加して、クライアントに返します。

Postmanでは以下の様に返ってくることが確認できます。
client_id, client_secretを:区切りでbase64エンコードしたものをBasic認証としてヘッダに付与することを忘れないでください。
ここで得られるaccess_tokenは、次にリソースサーバからリソースを取得する時に利用するので記録しておいてください。

$ curl -X POST -d "grant_type=authorization_code&code=YmY3YTc3ODItZjRhZi0zNTA4LWIyMzMtOGU2YmJhMWY0M2Vh&redirect_uri=http://example.com/" -H "Authorization: Basic MTIzNDU2Onh5eg==" http://localhost:9001/token

04-token-response

2. リソースサーバの構築

リソースサーバは以下のディレクトリ構造をしています。

.
├── Dockerfile
├── domain
│   ├── entity
│   │   └── resource.go
│   └── repository
│       └── resource.go
├── go.mod
├── go.sum
├── handler
│   └── resourceHandler.go
└── main.go

それでははじめにmain.goを実装していきましょう。

main.go
main.go
package main

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

	_ "github.com/joho/godotenv/autoload"

	"github.com/miyuki-starmiya/go-oauth2-server/db/store"
	"github.com/miyuki-starmiya/go-oauth2-server/resource/handler"
)

func main() {
	db, err := store.NewDatabase()
	if err != nil {
		log.Printf("Error: %v\n", err)
		return
	}

	rh := handler.NewResourceHandler(
		store.NewTokenStore(db),
	)
	http.HandleFunc("/resource", rh.HandleResourceRequest)

	port := "9002"
	host := "0.0.0.0"
	log.Printf("listen start: %s:%s\n", host, port)
	http.ListenAndServe(fmt.Sprintf("%s:%s", host, port), nil)
}

基本的には認可サーバと同じような処理しかしておらず、ストアを渡してハンドラを生成し、ルーティングしています。

2-1. リソース取得エンドポイント(/resource)

次にリソースを取得するハンドラを実装していきます。
リソースサーバのエンドポイントについては何の仕様もないので、基本的には好きにエンドポイントを作ってもらって大丈夫ですが、アクセストークンを正常に検証する必要があります。

リソース取得エンドポイントのハンドラ
resourceHandler.go
package handler

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/miyuki-starmiya/go-oauth2-server/db/store"
	"github.com/miyuki-starmiya/go-oauth2-server/resource/domain/repository"
)

func NewResourceHandler(ts *store.TokenStore) *ResourceHandler {
	return &ResourceHandler{
		TokenStore: ts,
	}
}

type ResourceHandler struct {
	TokenStore *store.TokenStore
}

func (rh *ResourceHandler) HandleResourceRequest(w http.ResponseWriter, r *http.Request) {
	if !rh.validateAccessToken(r) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	resource, _ := repository.GetResource()

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resource)
	log.Println("get resource successfully")
}

func (rh *ResourceHandler) validateAccessToken(r *http.Request) bool {
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" {
		log.Println("Authorization header is not set")
		return false
	}
	_, token, err := parseBearerToken(authHeader)
	if err != nil {
		log.Printf("error: %v\n", err)
		return false
	}

	tokenData, err := rh.TokenStore.GetData(r.URL.Query().Get("client_id"), token)
	if err != nil {
		log.Printf("Client ID and access token do not match: %v\n", err)
		return false
	}
	if tokenData.IssuedAt.Add(time.Duration(tokenData.ExpiresIn) * time.Second).Before(time.Now()) {
		log.Println("Access token has expired")
		return false
	}

	return true
}

func parseBearerToken(authHeader string) (string, string, error) {
	const bearerPrefix = "Bearer "
	if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
		return "", "", fmt.Errorf("Invalid authorization header")
	}

	return "Bearer", authHeader[len(bearerPrefix):], nil
}

注意する必要があるのが、アクセストークンはBearerトークンとして送られてくるので、Authorization ヘッダから適切に取得する必要がある点です。
あとは、アクセストークンとclient_idが正常か、トークンが失効していないかを確認して、正常であればリソースを返します。
リソースは本来Webサーバが接続してるDBから返すと思いますが、今回のソースコードでは簡易なメッセージを返すだけで、疎通の正常性のみを確認するものとしています。

Postmanでは以下の様に返ってくることが確認できます。
アクセストークンをBearerトークンとしてヘッダに付与することを忘れないでください。

$ curl -H "Authorization: Bearer OGZLYMY4MWITMGVJOS0ZODAZLTG4OGUTYTJIOGM0ZGU2YTQY" "http://localhost:9002/resource?client_id=123456"

05-resource-response

3. DBの接続

では、DBの接続や認可コードやアクセストークンの型定義を実施します。
これらはルートディレクトリから生やしたdb/に格納し、認可サーバ(auth/)とリソースサーバ(resource/)で、利用しています。

db/のディレクトリ構造は以下の様です。

.
├── go.mod
├── go.sum
├── model
│   ├── code.go
│   └── token.go
└── store
    ├── code.go
    ├── db.go
    └── token.go

db/の実装については、OAuth 2.0の実装には直接関係ない部分なので、詳細な説明は割愛させていただきます。
実装はこちらから参照できるので、適宜ご確認ください。

4. コンテナ化

それでは、最後にすべてのサーバをコンテナ化していきます。
まず、ルートディレクトリでdocker-composeファイルを作成します。

docker-compose.yml
docker-compose.yml
version: '3.6'

services:
  auth:
    build:
      context: .
      dockerfile: ./auth/Dockerfile
    ports:
      - "9001:9001"
    env_file:
      - ./.env
    networks:
      - go-oauth2-server_network

  resource:
    build:
      context: .
      dockerfile: ./resource/Dockerfile
    ports:
      - "9002:9002"
    env_file:
      - ./.env
    networks:
      - go-oauth2-server_network

  db:
    image: mongo:latest
    ports:
      - "${MONGO_PORT}:${MONGO_PORT}"
    volumes:
      - data:/data/db
    env_file:
      - ./.env
    depends_on:
      - auth
      - resource
    networks:
      - go-oauth2-server_network

volumes:
  data:

networks:
  go-oauth2-server_network:
    driver: "bridge"

環境変数として.envファイルを読み込む必要があるので、以下必要な環境変数を設定してください。

.env
CLIENT_ID=
CLIENT_SECRET=
REDIRECT_URI=

MONGO_INITDB_ROOT_USERNAME=
MONGO_INITDB_ROOT_PASSWORD=
MONGO_INITDB_DATABASE=
MONGO_USER=
MONGO_PASSWORD=
MONGO_DB=
MONGO_HOST=
MONGO_PORT=

最後に、認可サーバとリソースサーバのDockerfileを作成して完了です。

auth/Dockerfile
auth/Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY ./auth/go.mod ./auth/go.sum ./
# Adjust local directory structure
COPY ./db ../db

RUN go mod download

COPY ./auth .

RUN go build -o main .

FROM alpine:latest

WORKDIR /app

COPY --from=builder /app/main .

CMD ["./main"]
resource/Dockerfile
resource/Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY ./resource/go.mod ./resource/go.sum ./
# Adjust local directory structure
COPY ./db ../db

RUN go mod download

COPY ./resource ./

RUN go build -o main .

FROM alpine:latest

WORKDIR /app

COPY --from=builder /app/main .

CMD ["./main"]

これらがすべて作成できたらdocker-composeでコンテナ群を起動したら、実際に動作させることができます。
実際に動かすにはMongoDBのコレクションだけ事前に作成しておく必要があるので以下の作業を行ってから確認してみてください。

$ docker-compose up --build
$ docker-compose exec db bash

# in db container
$ mongosh -u <MONGO_USER> -p <MONGO_PASSWORD>
$ use go-oauth2-server
$ db.createCollection('codes')
$ db.createCollection('tokens')

おわりに

今回はじめてRFCを一からすべて読んで実装してみました。
RFCはものによっては巷の解説書を読むより、端的に要点がまとまっていたりするので、実際に読んで実装してみるというのはオススメできそうです!

それでは、良きアニメライフを!!

References

Discussion