2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-01-22

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

環境

OS macOS Catalina 10.15.7
Go 1.16.6
MySQL 8.0.26

目次

(1)パスワード保存 編
https://qiita.com/obr-note/items/8e4eb75585fba7efefdf

  • パスワードの脆弱性と対策について
  • パスワード文字数制限
  • アカウントロック
  • コード全文

(2)セッション管理 編
https://qiita.com/obr-note/items/6bc511f40c9086530c43

  • セッション状態をどこに保管するか
  • クッキーの概説
  • 【お手頃】Cookieに平文保存
  • XSS攻撃
  • 【改ざんされない】JWTを使う
  • 【情報がバレない】セッション用のライブラリを使う
  • 再生攻撃
  • コード全文

(3)トークン埋め込み 編
https://qiita.com/obr-note/items/ee566ec9c9cf1a5608de

  • CSRF攻撃
  • 全編のまとめコード

パスワードの脆弱性と対策について

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

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

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

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

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

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

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

パスワード文字数制限

実装は簡単。

password := r.FormValue("password")
if len(password) < 8 {
    http.Error(w, "Your password is too short", http.StatusBadRequest)
}

アカウントロック

ログイン失敗回数とロック日時のカラムをデータベースに追加する。

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;
zsh
$ mysql -u root < alter.sql

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

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分以内なら、エラーを返す。

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) {
	// エラー処理
}

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

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

ソルトとストレッチング

ライブラリを使います。
https://pkg.go.dev/golang.org/x/crypto/bcrypt

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

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

脆弱性対策後のコード全文

まずは、データベースを作成する。

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

コード全文はこちら。

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?