Rails+deviseに全任せしてた雑魚がGoでログイン機能をつくるまで【１-パスワード保存 編】

業務ではRails+deviseに頼りっきりの雑魚エンジニアが、GoでWebアプリ作るためログイン機能の実装に悪戦苦闘した記録です！

## 環境
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攻撃
- 全編のまとめコード

## パスワードの脆弱性と対策について
パスワードの脆弱性については下記が知られている。

|| 概説 |
|:-:|:-|
| ソーシャルエンジニアリング | なりすまし電話で聞き出したり、肩越しに画面を盗み見る |
| フィッシング | 本物そっくりの偽サイトに入力させる |
| ブルートフォース攻撃 | 文字列の組み合わせ全てを試す総当たり |
| 辞書攻撃 | パスワードに用いられやすい文字列を順に試す |
| ジョーアカウント攻撃 | ユーザーIDと同じ文字列をパスワードに設定する |
| リバースブルートフォース攻撃 | パスワードを固定してIDを変更していく総当たり |
| パスワードスプレー攻撃 | IDをまとめて固定して一つのワードへの攻撃を時間をおいて繰り返す |
| パスワードリスト攻撃 | 別のサイトから漏洩したIDとパスワードの組み合わせを試す |

これらの攻撃の詳しい説明と対策に関しては、徳丸本を読んでほしい。
この記事では、簡単に実装できる対策として
・パスワードの文字数を**8文字以上**に制限
・ログインに**10回失敗**したアカウントは**30分間ロック**
を実装する。

また、万が一データベースが第三者に盗まれた場合に、他のサイトでパスワードリスト攻撃に利用されないように、パスワードを暗号化してデータベースに保存する。
しかし、盗まれたデータベースは第三者がオフライン状態で解読できるので、下記の攻撃ように回数制限なく高速にパスワードの解読が行われてしまう。

|| 概説 |
|:-:|:-|
| オフラインブルートフォース攻撃 | 文字列の組み合わせ全てを試す総当たり |
| レインボーテーブル | あらかじめ作成しておいた逆引き表で総当たり |
|| ダミーのユーザーを多数登録してデータベースにパスワード辞書を作る |

対策として、**ソルト**と**ストレッチング**が必要となる。
ソルトとはユーザーの入力したパスワードに、ユーザー毎に異なる文字列を追加してある程度の長さにして、それをハッシュ化する方法だ。
ストレッチングとはハッシュ化の速度を遅くすることで、第三者の解読に時間をかけさせる方法だ。

この記事では、ソルトもストレッチングもやってくれる**BCrypt**のライブラリを利用して、パスワードをハッシュ化する。

## パスワード文字数制限
実装は簡単。

```go:
password := r.FormValue("password")
if len(password) < 8 {
    http.Error(w, "Your password is too short", http.StatusBadRequest)
}
```
## アカウントロック
ログイン失敗回数とロック日時のカラムをデータベースに追加する。

```sql:alter.sql
USE dbname;

ALTER TABLE users
ADD COLUMN failed_attempts INT NOT NULL DEFAULT 0 AFTER password,
ADD COLUMN locked_at DATETIME NOT NULL DEFAULT '1000-01-01 00:00:00' AFTER failed_attempts;
```
```sh:zsh
$ mysql -u root < alter.sql
```

パスワードが違った場合は、10回以上ならロック日時を記録してロックをかける。10回未満なら、ログイン失敗回数を1回増やす。

```go:
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)
}
```

ログイン日時がロック日時から30分以内なら、エラーを返す。

```go:
var (
	dbLayout  = "2006-01-02 15:04:05"
	jst, _    = time.LoadLocation("Asia/Tokyo")
)
now := time.Now()
if t, _ := time.ParseInLocation(dbLayout, user.LockedAt, jst); t.Add(30*time.Minute).After(now) {
	// エラー処理
}
```

ログインに成功したら、ログイン失敗回数をリセットする。

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

## ソルトとストレッチング
ライブラリを使います。
https://pkg.go.dev/golang.org/x/crypto/bcrypt

```go:
// パスワード生成
hashed_password, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

// パスワード復号
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password")));
```

## 脆弱性対策後のコード全文
まずは、データベースを作成する。

```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;
```
```sh:zsh
$ mysql -u root < init.sql # ユーザー名root、パスワード無しの場合
```

コード全文はこちら。

```go:main.go
package main

import (
	"database/sql"
	"fmt"
	"net/http"
	"time"

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

// 設定値
var (
	layout = "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 main() {
	http.HandleFunc("/signup", signup)
	http.HandleFunc("/login", login)

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

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

		email := r.FormValue("email")
		password := r.FormValue("password")

		// パスワードが8文字未満か？
		if len(password) < 8 {
			http.Error(w, "Too short password", http.StatusBadRequest)
			return
		}

		// 既に存在するemailか？
		var exists bool
		db.
			QueryRow("SELECT EXISTS ( SELECT 1 FROM users WHERE email = ? LIMIT 1)", email).
			Scan(&exists)
		if exists {
			http.Error(w, "Already used email", http.StatusBadRequest)
			return
		}

		// パスワードを暗号化
		hashed_password, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

		// データベースへ登録
		db.Exec("INSERT INTO users(email, password) VALUES(?, ?);", email, hashed_password)

		fmt.Fprintln(w, "Registration completed")
		return
	}

	// サインアップフォーム
	tmpl := `
<!DOCTYPE html>
<body>
	<h1>Sign Up</h1>
	<form method="post" action="/signup">
		<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>`

	fmt.Fprintln(w, tmpl)
}

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

		// emailが一致するユーザーを検索
		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, "Wrong email", http.StatusForbidden)
			return
		}

		// ロックから30分以内のログインは許可しない
		now := time.Now()
		if t, err := time.ParseInLocation(layout, user.LockedAt, jst); err == sql.ErrNoRows || t.Add(30*time.Minute).After(now) {
			http.Error(w, "Locked account", http.StatusForbidden)
			return
		}

		// パスワードが正しいか？
		if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); err != nil {
			if user.FailedAttempts >= 10 {
				// ログイン失敗回数が10回以上の場合はロックをかける
				db.Exec("UPDATE users SET failed_attempts=?, locked_at=NOW() WHERE id=?", user.FailedAttempts+1, user.Id)
			} else {
				// ログイン失敗回数が10回未満の場合は失敗回数を増やす
				db.Exec("UPDATE users SET failed_attempts=? WHERE id=?", user.FailedAttempts+1, user.Id)
			}
			http.Error(w, "Wrong password", http.StatusForbidden)
			return
		}

		// ログインに成功した場合はログイン失敗回数をリセット
		db.Exec("UPDATE users SET failed_attempts=0 WHERE id=?", user.Id)

		fmt.Fprintln(w, "You are logged in as", user.Id)
		return
	}

	// ログインフォーム
	tmpl := `
<!DOCTYPE html>
<body>
	<h1>Login</h1>
	<form method="post" action="/login">
		<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>`

	fmt.Fprintln(w, tmpl)
}
```

【セッション管理編】へ続く
https://qiita.com/obr-note/items/6bc511f40c9086530c43