Rails+deviseに全任せしてた雑魚がGoでログイン機能をつくるまで【３-トークン埋め込み 編】

業務でRails+deviseに頼りっきりの雑魚エンジニアが、Goでポートフォリオ作ろうとログイン機能の実装に悪戦苦闘した記録です！

## 環境
OS macOS Catalina 10.15.7
Go 1.16.6
MySQL 8.0.26

## 目次
**（１）パスワード保存 編**
https://qiita.com/obr-note/items/8e4eb75585fba7efefdf
- パスワードの脆弱性と対策について
- パスワード文字数制限
- アカウントロック
- コード全文

**（２）セッション管理 編**
https://qiita.com/obr-note/items/6bc511f40c9086530c43
- セッション状態をどこに保管するか
- クッキーの概説
- 【お手頃】Cookieに平文保存
- XSS攻撃
- 【改ざんされない】JWTを使う
- 【情報がバレない】セッション用のライブラリを使う
- 再生攻撃
- コード全文

**（３）トークン埋め込み 編**
https://qiita.com/obr-note/items/ee566ec9c9cf1a5608de
- CSRF攻撃
- 全編のまとめコード

## CSRF攻撃
CSRF（シーサーフ）とはクロスサイトリクエストフォージェリの略で、正規サイトにログイン状態のユーザーに攻撃者が罠サイトを踏ませて、ユーザーになりすまして正規サイトの更新処理をしてしまう手法だ。

こちらの記事を参考にした。
https://matope.hatenablog.com/entry/2019/06/05/144435

```go:正規サイト
// main.go
package main

import (
	"fmt"
	"net/http"

	"github.com/gorilla/sessions"
)

var (
	store = sessions.NewCookieStore([]byte("32-byte-long-auth-key"))
)

func main() {
	http.HandleFunc("/login", login)
	http.HandleFunc("/form", form)
	http.HandleFunc("/form/post", submitForm)

	http.ListenAndServe(":8080", nil)
}

// ログイン処理
func login(w http.ResponseWriter, r *http.Request) {
	// Cookieをセット
	session, _ := store.Get(r, "cookie-name")
	session.Values["authenticated"] = true
	session.Save(r, w)

	fmt.Println("You logged in")
}

// 更新フォーム
func form(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, `
<html>
<body>
	<form method="POST" action="/form/post" accept-charset="UTF-8">
		<input type="text" name="content">
		<input type="submit" value="Submit">
	</form>
</body>
</html>`)
}

// 更新処理
func submitForm(w http.ResponseWriter, r *http.Request) {
	if r.Method == "POST" {
		session, _ := store.Get(r, "cookie-name")

		if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}

		_ = r.ParseForm()
		fmt.Fprintf(w, "%v\n", r.PostForm)
	}
}
```
```go:罠サイト
// attack.go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/attack", attack)

	http.ListenAndServe(":1234", nil)
}

func attack(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, `
<html>
<body>
  <form method="post" action="http://localhost:8080/form/post"  accept-charset="UTF-8">
    <input type="hidden" name="content" value="cracked">
    <input type="submit" value="Win Money!">
  </form>
</body>
</html>`)
}
```

両方のサーバーを立ち上げる

```sh:zsh
$ go run main.go
$ go run attack.go
```

ここで、正規サイトにログインした後、罠サイトでボタンをクリックすると更新処理が行われてしまう。
http://localhost:8080/login
http://localhost:1234/attack
正規サイトへCookieが送られるので、正しいユーザーからのリクエストと認識されてしまう。
この他にもCSRFの手法として、iframeやjavascriptを使ってユーザーが気がつかない内に更新処理が行われてしまう巧妙な手口があるようだ。

### 対策
対策としては、
・トークン埋め込み
・更新処理前に再度パスワード入力
・ヘッダーのRefererチェック
などがある。
ここでは、トークン埋め込みを実装してくれるライブラリを利用する。
https://github.com/gorilla/csrf

```go:（抜粋）
// 便宜上ルーティングライブラリ（https://github.com/gorilla/mux）を使用

// ルーティング
func main() {
	r := mux.NewRouter()
	r.HandleFunc("/login", login)
	r.HandleFunc("/form", form)
	r.HandleFunc("/form/post", submitForm)

	h := csrf.Protect([]byte("32-byte-long-auth-key"), csrf.Secure(false))(r)
    http.ListenAndServe(":8080", h)
}

// トークンを埋め込む
t, _ := template.New("form").Parse(`
<html>
<body>
	<form method="POST" action="/form/post" accept-charset="UTF-8">
		{{.CSRFField}}
		<input type="text" name="content">
		<input type="submit" value="Submit">
	</form>
</body>
</html>`)

t.Execute(w, map[string]interface{}{
	"CSRFField": csrf.TemplateField(r),
})
```

## 全編のまとめコード

```sh:
.
├── go.mod
├── go.sum
├── init.sql
└── main.go
```
```sql:init.sql
CREATE DATABASE IF NOT EXISTS dbname character SET utf8mb4 collate utf8mb4_bin;

USE dbname;

DROP TABLE IF EXISTS users;

CREATE TABLE IF NOT EXISTS users (
  id INT UNSIGNED NOT NULL PRIMARY KEY auto_increment,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  failed_attempts INT NOT NULL DEFAULT 0,
  locked_at DATETIME NOT NULL DEFAULT '1000-01-01 00:00:00',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) character SET utf8mb4 collate utf8mb4_bin;
```
```go:main.go
package main

import (
	"database/sql"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/gorilla/csrf"
	"github.com/gorilla/mux"
	"github.com/gorilla/sessions"
	"golang.org/x/crypto/bcrypt"
)

// テスト用設定値
var (
	verifyKey = []byte("32-byte-long-auth-key")
	store     = sessions.NewCookieStore(verifyKey)
	layout    = "2006-01-02 15:04:05 +0900 JST"
	dbLayout  = "2006-01-02 15:04:05"
	jst, _    = time.LoadLocation("Asia/Tokyo")
)

type User struct {
	Id             int    `json:"id"`
	Email          string `json:"email"`
	Password       string `json:"password"`
	FailedAttempts int    `json:"failed_attempts"`
	LockedAt       string `json:"locked_at"`
	CreatedAt      string `json:"created_at"`
	UpdatedAt      string `json:"updated_at"`
}

// ログイン状態でなければ閲覧できないページ
func secret(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "Session")

	if auth, ok := session.Values["Login"].(bool); !ok || !auth {
		http.Error(w, "Forbidden - Not logged in", http.StatusForbidden)
		return
	}

	if str, ok := session.Values["ExpiredAt"].(string); !ok {
		http.Error(w, "Forbidden - Expired", http.StatusForbidden)
		return
	} else {
		// 期限を過ぎた場合はエラーを返す
		now := time.Now()
		if t, err := time.ParseInLocation(layout, str, jst); err != nil || t.Before(now) {
			http.Error(w, "Forbidden - Expired", http.StatusForbidden)
			return
		}
	}

	fmt.Fprintln(w, "You are logged in as", session.Values["UserId"])
}

// ログイン
func login(w http.ResponseWriter, r *http.Request) {
	if r.Method == "POST" {
		db, _ := sql.Open("mysql", "root@tcp(localhost:3306)/dbname")
		defer db.Close()

		var user User
		err := db.
			QueryRow("SELECT id, email, password, failed_attempts, locked_at FROM users WHERE email=?", r.FormValue("email")).
			Scan(&user.Id, &user.Email, &user.Password, &user.FailedAttempts, &user.LockedAt)

		if err != nil {
			http.Error(w, "Forbidden - Wrong email", http.StatusForbidden)
			return
		}

		now := time.Now()
		if t, err := time.ParseInLocation(dbLayout, user.LockedAt, jst); err == sql.ErrNoRows || t.Add(30*time.Minute).After(now) {
			http.Error(w, "Forbidden - Locked account", http.StatusForbidden)
			return
		}

		if err := bcrypt.CompareHashAndPassword(
			[]byte(user.Password),
			[]byte(r.FormValue("password"))); err != nil {
			if user.FailedAttempts >= 10 {
				db.Exec("UPDATE users SET failed_attempts=?, locked_at=NOW() WHERE id=?", user.FailedAttempts+1, user.Id)
			} else {
				db.Exec("UPDATE users SET failed_attempts=? WHERE id=?", user.FailedAttempts+1, user.Id)
			}
			http.Error(w, "Forbidden - Wrong password", http.StatusForbidden)
			return
		}

		db.Exec("UPDATE users SET failed_attempts=0 WHERE id=?", user.Id)

		session, _ := store.Get(r, "Session")
		session.Options.HttpOnly = true

		session.Values["Login"] = true
		session.Values["UserId"] = user.Id

		if r.FormValue("is_autologin_on") == "on" {
			session.Values["ExpiredAt"] = time.Now().AddDate(0, 0, 7).Format(layout)
			session.Options.MaxAge = 60 * 60 * 24 * 7
		} else {
			session.Values["ExpiredAt"] = time.Now().Add(time.Hour).Format(layout)
			session.Options.MaxAge = 0                                         
		}
		session.Save(r, w)

		http.Redirect(w, r, "http://localhost:8080/secret", http.StatusMovedPermanently)
	}

	t, _ := template.New("Login").Parse(`
<!DOCTYPE html>
<body>
	<h1>Login</h1>
	<form method="post" action="/login">
		{{.CSRFField}}
		<label for="email">email:</label><br>
		<input type="email" id="email" name="email" pattern=".+@.+\..+" size="30" required><br><br>
		<label for="password">password:</label><br>
		<input type="password" id="password" name="password" minlength="8" required><br><br>
		<label for="is_autologin_on">auto login:</label>
		<input type="checkbox" id="is_autologin_on" name="is_autologin_on"><br><br>
		<input type="submit" value="Submit">
	</form>
</body>`)

	t.Execute(w, map[string]interface{}{
		"CSRFField": csrf.TemplateField(r),
	})
}

func logout(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "Session")

	session.Options.MaxAge = -1
	session.Save(r, w)

	fmt.Fprintln(w, "You logged out")
}

// サインアップ
func signup(w http.ResponseWriter, r *http.Request) {

	if r.Method == "POST" {
		db, _ := sql.Open("mysql", "root@tcp(localhost:3306)/dbname")
		defer db.Close()

		var exists bool
		email := r.FormValue("email")
		db.
			QueryRow("SELECT EXISTS ( SELECT 1 FROM users WHERE email = ? LIMIT 1)", email).
			Scan(&exists)
		if exists {
			http.Error(w, "Your email is already used", http.StatusBadRequest)
			return
		}
		password := r.FormValue("password")
		if len(password) < 8 {
			http.Error(w, "Your password is too short", http.StatusBadRequest)
		}
		hashed_password, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
		if err != nil {
			log.Fatal(err)
		}

		_, err = db.Exec("INSERT INTO users(email, password) VALUES(?, ?);", email, hashed_password)
		if err != nil {
			log.Fatal(err)
		}

		http.Redirect(w, r, "http://localhost:8080/login", http.StatusMovedPermanently)
	}

	t, _ := template.New("Signup").Parse(`
<!DOCTYPE html>
<body>
	<h1>Sign Up</h1>
	<form method="post" action="/signup">
		{{.CSRFField}}
		<label for="email">email:</label><br>
		<input type="email" id="email" name="email" pattern=".+@.+\..+" size="30" required><br><br>
		<label for="password">password:</label><br>
		<input type="password" id="password" name="password" minlength="8" required><br><br>
		<input type="submit" value="Submit">
	</form>
</body>`)

	t.Execute(w, map[string]interface{}{
		"CSRFField": csrf.TemplateField(r),
	})
}

func main() {
	r := mux.NewRouter()
	r.HandleFunc("/secret", secret)
	r.HandleFunc("/login", login)
	r.HandleFunc("/logout", logout)
	r.HandleFunc("/signup", signup)

	h := csrf.Protect(verifyKey, csrf.Secure(false))(r)
	http.ListenAndServe(":8080", h)
}
```

※ 便宜上、エラーメッセージに理由を返していますが、脆弱性に繋がるのでユーザーに分からないように修正して使ってください。


終わり