【Golang】OAuthでログインを実装してみよう！

# OAuthでログイン処理を実装してみる！
単純な作りですが、OAuthを使ったGoogleアカウントでのログイン処理を実装してみます。
今回の目標は、ユーザーが自身のGoogleアカウントでシステムにログインできるようにすることです。
また、付随して考慮すべきセキュリティへの対応も行ってみたいと思います。

## 概要
OAuthを使用したログイン処理の概要を記載します。
### ユースケース
1. ユーザーがログイン画面から「Googleでログイン」ボタンを押下する
2. ユーザーの画面がOAuthのログイン画面に切り替わり、Googleアカウントを選択し、アクセスを許可する
3. ユーザーがアクセスを許可し、認証されるとログイン後の画面（今回はウェルカムページ）へ遷移できるようになる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3771904/54f9654e-5e03-d501-08f1-ceccb3c04484.png)

### 具体的な流れ
ユースケースを実現するに当たって、4つのステップを追って処理します。
1. ユーザーが「Googleでログインする」ボタンを押下すると、サーバーは**認証URL**を生成し、そのURLにユーザーをリダイレクトし、Googleアカウントでのログイン画面に遷移させる
2. ユーザーがGoogleアカウントでのアクセスを許可すると、サーバーは、Googleから送信される**一時的に有効な認証コード**を取得し、またGoogleにお願いして、**認証コードをアクセストークン（ユーザー情報を取得するための鍵）に交換**してもらう
3. サーバーはユーザーのGoogleアカウントからユーザー情報を取得する。**アクセストークンを使用してユーザー情報にアクセス**する必要がある
4. 取得したユーザー情報は、アプリ内で使用できるようにクッキーに保存するなどする

## クッキーに保存するときのセキュリティ対策
クッキーにそのままアクセストークンをもとに取得したユーザー情報を保存してしまうと、平文のままクッキーに保存されてしまう。
これではXSS（クロスサイトスクリプティング＝document.cookieなどでクッキーを盗むような攻撃）やCSRF（クロスサイトリクエストフォージェリ＝他サイトからクッキーを要求された際に送信してしまう）などの攻撃に対して、対応できない。

XSSに対しては、クッキーに保存する際に、**HttpOnly属性をtrue**にすることで、JavaScriptからクッキーにアクセスできなくなる（document.cookieではクッキーにアクセスできない）ようにすることができる。
CSRFに対しては、クッキーに保存する際に、**SameSite属性をStrict**にすることで、他サイトからのリクエスト時にクッキーが送信されなくなる。
また、このほかにも**セキュア属性をtrue**にすることで、HTTPS通信時のみクッキーを送信することができるようになり、HTTP通信ではクッキーが送信されなくなる。

しかし、これらの対応を行なっても、ユーザー情報をそのままクッキーに保存するのはセキュリティリスクがある。
そこで今回は、**クライアントにはセッションIDのみを返し、サーバー側でRedisを使用して、セッションIDとユーザー情報を紐付けて管理する**ようにしてみる。
Redisは、key-value storeと呼ばれるインメモリデータベースで、高速に読み書きすることができる。

### ログイン成功後、クッキーを保存する流れ
- サーバー側の処理
    1. ログインに成功すると、アクセストークンを使用してユーザーのGoogleアカウントからユーザー情報（名前、メールアドレス）を取得
    2. ユーザーに返すセッションIDを生成
    3. セッションIDと取得したユーザー情報をマッピングしてRedisに登録
    4. クライアントにセッションIDを返す

</br>

- セッションIDをクッキーに保存した後のクライアントからの要求に対しての処理
    1. クライアントはセッションIDをサーバーに送信
    2. サーバーはセッションIDをもとにRedisで照会し、セッションIDがあれば処理を続け、セッションIDが見つからなければユーザーにログインを促す

## OAuthとRedisの事前準備
### OAuth
- Google Cloud Consoleにて、「APIとサービス」→「認証情報」→「OAuth 2.0 クライアント ID」でクライアントIDを作成
- アプリケーションの種類に「ウェブアプリケーション」を選択
- 「承認済みのリダイレクトURL」に「[http://localhost:8080/auth/callback](http://localhost:8080/auth/callback)」を設定
### Redis
- Dockerを使用する
```bash
docker run --name redis-session -d -p 6379:6379 redis
```

## サンプルコード
### サーバー（Go）
必要なライブラリのインポート
```bash
go get golang.org/x/oauth2
go get golang.org/x/oauth2/google
go get github.com/redis/go-redis/v9
go get github.com/google/uuid
```
```main.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/google/uuid"
	"github.com/redis/go-redis/v9"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"html/template"
	"log"
	"net/http"
	"time"
)

// Redisクライアントのセットアップ
var redisClient = redis.NewClient(&redis.Options{
	Addr:     "localhost:6379",
	Password: "",
	DB:       0,
})

var ctx = context.Background()

// Google OAuthの設定
// oauth2.CongifにGoogle OAuthの設定を入れる
// Scopesで「ユーザーの名前」と「メールアドレス」の取得権限を設定
var googleOauthConfig = &oauth2.Config{
	ClientID:     "クライアントID",
	ClientSecret: "クライアントシークレット",
	RedirectURL:  "http://localhost:8080/auth/callback",
	Scopes:       []string{"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"},
	Endpoint:     google.Endpoint,
}

// User ユーザー情報を取得する構造体
type User struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

// ルートページ（ログイン画面）
// login.htmlを表示
// 「Googleでログイン」ボタンを押下すると、oauthGoogleLoginに移動
func loginHandler(w http.ResponseWriter, r *http.Request) {
	tmpl, _ := template.ParseFiles("login.html")
	tmpl.Execute(w, nil)
}

// Google OAuth認証開始
// Google OAuthの認証URLを生成
// そのURLへリダイレクトし、ユーザーにGoogleアカウントでの認証を要求
func oauthGoogleLogin(w http.ResponseWriter, r *http.Request) {
	// Googleの認証URLを生成
	url := googleOauthConfig.AuthCodeURL("randomstate")
	// そのURLへリダイレクトし、Googleのログインページを開く
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// Google OAuth認証後のコールバック
// Googleでの認証が完了すると、Goのバックエンドから認証コードが送られる
// 認証コードを使ってGoogleからアクセストークンを取得
// アクセストークンを使い、GoogleのAPIからユーザー情報を取得
// 取得したユーザー情報（名前・メールアドレス）をクッキーに保存
func oauthGoogleCallback(w http.ResponseWriter, r *http.Request) {
	// 認証後、Googleから渡されるcodeを取得
	code := r.URL.Query().Get("code")
	// codeがない場合は、エラーを返す
	if code == "" {
		http.Error(w, "認証コードが見つかりません", http.StatusBadRequest)
		return
	}

	// トークン取得（認証コードをアクセストークンに変換）
	// Exchangeでcodeを使い、アクセストークンを取得
	// これが成功すれば、GoogleのAPIを使ってユーザー情報を取得できる
	token, err := googleOauthConfig.Exchange(context.Background(), code)
	if err != nil {
		http.Error(w, "トークンの交換に失敗しました", http.StatusInternalServerError)
		return
	}

	// ユーザー情報取得（GoogleのAPIでユーザー情報を取得）
	// アクセストークン付きのHTTPクライアントを作成し、GoogleのAPIを呼び出す。
	client := googleOauthConfig.Client(context.Background(), token)
	resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
	if err != nil {
		http.Error(w, "ユーザー情報の取得に失敗しました", http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	// 取得したユーザー情報を解析
	// json.NewDecoder(resp.Body).Decode(&user)でユーザー情報をUser構造体に格納
	var user User
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		http.Error(w, "JSONデコードエラー", http.StatusInternalServerError)
		return
	}

	// 一意のセッションIDを生成
	sessionID := uuid.NewString()

	// RedisにセッションID→ユーザー情報を保存（有効期限30分）
	userData, _ := json.Marshal(user)
	err = redisClient.Set(ctx, sessionID, string(userData), 30*time.Minute).Err()
	if err != nil {
		http.Error(w, "セッション保存に失敗しました", http.StatusInternalServerError)
		return
	}

	// クッキーにセッションIDのみを保存
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
		Expires:  time.Now().Add(30 * time.Minute),
	})

	// ログイン後のページへリダイレクト
	http.Redirect(w, r, "/welcome", http.StatusSeeOther)
}

// ログイン後のページ
func welcomeHandler(w http.ResponseWriter, r *http.Request) {
	cookie, err := r.Cookie("session_id")
	if err != nil {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	sessionID := cookie.Value
	userData, err := redisClient.Get(ctx, sessionID).Result()
	if errors.Is(err, redis.Nil) {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	} else if err != nil {
		http.Error(w, "セッション取得に失敗しました。", http.StatusInternalServerError)
		return
	}

	var user User
	if err := json.Unmarshal([]byte(userData), &user); err != nil {
		http.Error(w, "ユーザーデータのデコードに失敗しました。", http.StatusInternalServerError)
		return
	}

	data := map[string]string{
		"Name":  user.Name,
		"Email": user.Email,
	}

	tmpl, _ := template.ParseFiles("welcome.html")
	tmpl.Execute(w, data)
}

// ログアウト処理
// クッキーを削除してログアウト
func logoutHandler(w http.ResponseWriter, r *http.Request) {
	cookie, err := r.Cookie("session_id")
	if err == nil {
		// Redisからセッションを削除
		redisClient.Del(ctx, cookie.Value)
	}

	// クライアントのクッキーを削除
	http.SetCookie(w, &http.Cookie{
		Name:    "session_id",
		Value:   "",
		Path:    "/",
		Expires: time.Unix(0, 0),
	})

	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func main() {
	// 使い方：以下のコマンドを実行して、Redisを起動後、Goアプリを実行する
	// docker run --name redis-session -d -p 6379:6379 redis
	http.HandleFunc("/", loginHandler)
	http.HandleFunc("/auth/google", oauthGoogleLogin)
	http.HandleFunc("/auth/callback", oauthGoogleCallback)
	http.HandleFunc("/welcome", welcomeHandler)
	http.HandleFunc("/logout", logoutHandler)

	fmt.Println("Server running at http://localhost:8080/")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

```

### ログイン画面
```html:login.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<a href="/auth/google">
    <button>Googleでログイン</button>
</a>
</body>
</html>
```
### ウェルカムページ
```html:welcome.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ようこそ</title>
</head>
<body>
<h1>ようこそ, {{.Name}} さん！</h1>
<p>メール: {{.Email}}</p>
<a href="/logout">
    <button>ログアウト</button>
</a>
</body>
</html>
```


## 最後に
自前でログインを実装すると、ユーザーの権限をカスタマイズできたりして便利ですが、それなりに実装が大変になりますよね。
そこまでユーザーの権限が細かくない場合は、OAuthを使用してGoogleやGithubなどのアカウントでログインできるようにすると、新規登録もしなくていいし、便利なのかなと思います。

以上です。
内容に不備やお気づきの点がございましたら、ご指摘よろしくお願いいたします。