おまけ: 安全なパスワードの保存方法

パスワードのハッシュ化

パスワードをそのままデータベースに保存するのは、セキュリティ的に問題があります。

例えば、SQLインジェクション などの攻撃により、データベースの内容が盗み見られたり 内部の人間がデータを持ち出し、インターネットに(意図的かどうかは別として)公開してしまう、 といったシチュエーションを考えてみましょう。


ユーザー名とパスワードがそのまま保存されている場合 利用者が別のシステムのアカウントとパスワードを使いまわしていると それらのシステムに不正アクセスされるなど、二次被害が予想されます。

そのような事にならないように、ユーザーのパスワードをそのまま保存するのではなく 別の形に変換して保存するようにし、万が一データベースを盗み見られても パスワードがわからないようにします。


今回は Webアプリケーションにてよく使用される パスワードをハッシュ化する 手法について解説します。


補足説明: ハッシュ関数

ハッシュ関数とは、文字列の内容を一定長の文字列に変換する関数です。

ハッシュ化された値をハッシュ値と呼びます。
ハッシュ値から元の文字列を計算できないという一方向性が特徴です(不可逆的な一方向の関数)。

代表的なハッシュ関数として、以下の様なものがあります。

  • MD5
  • SHA1
  • SHA256



参考: C# で文字列をハッシュ化する

C#で文字列を ハッシュ関数 MD5 でハッシュ化する場合、以下のようにコーディングします。

// MD5ハッシュ値を計算する文字列
string s = "password";
// 文字列をbyte型配列に変換する
byte[] data = System.Text.Encoding.UTF8.GetBytes(s);

// MD5CryptoServiceProviderオブジェクトを作成
var md5 = new System.Security.Cryptography.MD5CryptoServiceProvider();
//または、次のようにもできる
// var md5 = System.Security.Cryptography.MD5.Create();

// ハッシュ値を計算する
byte[] bs = md5.ComputeHash(data);

// リソースを解放する
md5.Clear();

// byte型配列を16進数の文字列に変換
System.Text.StringBuilder result = new System.Text.StringBuilder();
foreach (byte b in bs)
{
    result.Append(b.ToString("x2"));
}
//ここの部分は次のようにもできる
// string result = BitConverter.ToString(bs).ToLower().Replace("-","");

// 結果を表示
Console.WriteLine(result);



補足説明: salt (ソルト)

ハッシュ関数は不可逆的な一方向の関数であり、ハッシュ値から元の文字列を割り出すには 膨大な計算が必要になります。

しかしながら、レインボーテーブル という攻撃手段によって 元の文字列が短ければ、現実的な計算時間で元の文字列を割り出せてしまいます。

レインボーテーブル の対策としては salt (ソルト) を利用するのが効果的です。

salt とは以下の要件を満たす文字列です。

  • ユーザー毎に違うこと
  • ある程度の長さがあること(20桁以上が目安)

簡単な salt の生成方法としては、ユーザーIDをハッシュ関数でハッシュ化した値を salt として利用する方法があります。

salt は具体的には、以下のように処理します。

ハッシュ化パスワード = ハッシュ関数(salt + パスワード)



補足説明: ストレッチング

ストレッチングとは、以下のような処理を複数回繰り返してパスワードをより強力に保護する方法です。

ハッシュ値 = ハッシュ関数(計算後のハッシュ値 + salt + パスワード)

セキュリティ企業のソフォスがストレッチングを 10,000 回以上行うことを最低条件としているそうです。



補足説明: PBKDF2 (Password-Based Key Derivation Function 2)

PBKDF2 (Password-Based Key Derivation Function 2) is a key derivation function that is part of RSA Laboratories' Public-Key Cryptography Standards (PKCS) series, specifically PKCS #5 v2.0, also published as Internet Engineering Task Force's RFC 2898. It replaces an earlier standard, PBKDF1, which could only produce derived keys up to 160 bits long.
PBKDF2

RSA研究所の公開鍵暗号化標準仕様 (PKCS) の一部で、 RFC 2898 として提案されている方法です。

ハッシュ化、salt、ストレッチングを組み合わせてパスワードを保護する方法について定義しています。



参考: PBKDF2 の C#での実装例

Rfc2898DeriveBytes クラス を使用すると、PBKDF2 に基づいたハッシュ値を取得できます。

using System;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;

namespace Keicode.Security
{
  class HashUtil
  {
    const int PBKDF2_ITERATION = 10000;

    public static string GeneratePasswordHashPBKDF2( string pwd, string salt )
    {
      var result = "";
      var encoder = new UTF8Encoding();

      var b = new Rfc2898DeriveBytes( pwd, encoder.GetBytes( salt ), PBKDF2_ITERATION );
      var k = b.GetBytes( 32 );
      result = Convert.ToBase64String( k );
      return result;
    }
  }
}

パスワード、salt、ストレッチングの回数を指定します。 上記の例では Byte で結果を受け取り、base64の形式で文字列に変換しています。



補足説明: Base64

Base64は、データを64種類の印字可能な英数字のみを用いて、それ以外の文字を扱うことの出来ない通信環境にてマルチバイト文字やバイナリデータを扱うためのエンコード方式である。
MIMEによって規定されていて、7ビットのデータしか扱うことの出来ない電子メールにて広く利用されている。具体的には、A–Z, a–z, 0–9 までの62文字と、記号2つ (+, /)、さらにパディング(余った部分を詰める)のための記号として = が用いられる。
この変換によって、データ量は4/3(約133%)になる。また、MIMEの基準では76文字ごとに改行コードが入るため、この分の2バイトを計算に入れるとデータ量は約137%となる。
Base64



補足説明: MIME

Multipurpose Internet Mail Extension(多目的インターネットメール拡張)は、規格上US-ASCIIのテキストしか使用できないインターネットの電子メールでさまざまなフォーマット(書式)を扱えるようにする規格である。通常はMIME(マイム)と略される。
RFC 2045、RFC 2046、RFC 2047、RFC 4288、RFC 4289、RFC 2049 で規定されている。
Multipurpose Internet Mail Extensions



薬品検索システムへの組み込み

PBKDF2でパスワードを保護する

認証処理を修正します。

Models/CustomMembershipProvider.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Web;
using System.Web.Security;

namespace WebApplication1.Models
{
    public class CustomMembershipProvider : MembershipProvider
    {
        /// <summary>
        /// ストレッチング回数
        /// </summary>
        const int STRETCHING_TIMES = 10000;

        /* ~~ 省略 ~~ */

        public override bool ValidateUser(string username, string password)
        {
            using (var db = new DrugInfoContext())
            {
                // ユーザー名とパスワードからパスワードハッシュを取得
                string passhash = this.GeneratePasswordHash(username, password);

                var user = db.Users.Where(u => u.UserName.Equals(username) && u.Password.Equals(passhash)).FirstOrDefault();
                if (user != null)
                {
                    return true;
                }
            }

            // TODO: 後で削除する
            if ("administrator".Equals(username) && "password".Equals(password))
            {
                return true;
            }
            return false;
        }

        /// <summary>
        /// UserNameからsaltを取得
        /// </summary>
        /// <param name="username"></param>
        /// <returns></returns>
        private string GenerateSalt(string username)
        {
            // 文字列をbyte配列に変換
            var data = System.Text.Encoding.UTF8.GetBytes(username);
            // SHA256CryptoServiceProvider
            var sha256 = new SHA256CryptoServiceProvider();
            // ハッシュ値を計算する
            var hash = sha256.ComputeHash(data);
            // 文字列に変換
            string result = BitConverter.ToString(hash).ToLower().Replace("-", "");

            return result;
        }

        /// <summary>
        /// ユーザー名とパスワードからパスワードハッシュを取得
        /// </summary>
        /// <param name="username"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        public string GeneratePasswordHash(string username, string password)
        {
            // saltを取得
            string salt = this.GenerateSalt(username);

            // PBKDF2でパスワードをハッシュ化
            var pbkdf2 = new Rfc2898DeriveBytes(password,
                System.Text.Encoding.UTF8.GetBytes(salt),
                STRETCHING_TIMES);

            // パスワードハッシュ(byte)をbase64形式の文字列に変換
            string result = Convert.ToBase64String(pbkdf2.GetBytes(32));

            return result;
        }
    }
}


Controllers/LoginController.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Security;
using WebApplication1.Models;

namespace WebApplication1.Controllers
{
    [AllowAnonymous]
    public class LoginController : Controller
    {
        readonly CustomMembershipProvider membershipProvider = new CustomMembershipProvider();
        private DrugInfoContext db = new DrugInfoContext();

        // GET: Login
        public ActionResult Index()
        {
            return View();
        }

        // POST: Login
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Index([Bind(Include = "UserName,Password")] LoginViewModel model)
        {
            if (ModelState.IsValid)
            {
                if (this.membershipProvider.ValidateUser(model.UserName, model.Password))
                {
                    string passhash = membershipProvider.GeneratePasswordHash(model.UserName, model.Password);

                    var user = db.Users.Where(u => u.UserName.Equals(model.UserName)
                        && u.Password.Equals(passhash))
                        .FirstOrDefault();

                    if (user != null)
                    {
                        FormsAuthentication.SetAuthCookie(user.UserId.ToString(), false);
                        return RedirectToAction("Index", "Home");
                    }

                    // TODO: 後で削除する
                    if ("administrator".Equals(model.UserName) && "password".Equals(model.Password))
                    {
                        FormsAuthentication.SetAuthCookie("1", false);
                        return RedirectToAction("Index", "Home");
                    }
                }
            }
            ViewBag.Message = "ログインに失敗しました。";
            return View(model);
        }

        // GET: Login/SignOut
        public ActionResult SignOut()
        {
            FormsAuthentication.SignOut();
            return RedirectToAction("Index");
        }
    }
}


Controllers/UsersController.cs


using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Web.Mvc;
using WebApplication1.Models;

namespace WebApplication1.Controllers
{
    public class UsersController : Controller
    {
        private DrugInfoContext db = new DrugInfoContext();
        readonly CustomMembershipProvider membershipProvider = new CustomMembershipProvider();

        /* ~~ 省略 ~~ */

        // POST: Users/Create
        // 過多ポスティング攻撃を防止するには、バインド先とする特定のプロパティを有効にしてください。
        // 詳細については、http://go.microsoft.com/fwlink/?LinkId=317598 を参照してください。
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create([Bind(Include = "UserId,UserName,Password,RoleId")] Users users)
        {
            if (ModelState.IsValid)
            {
                foreach(int roleId in users.RoleId)
                {
                    var userRole = new UserRole
                    {
                        UserID = users.UserId,
                        RoleID = roleId
                    };
                    users.UserRole.Add(userRole);
                }

                // パスワードをハッシュ化
                string hash = membershipProvider.GeneratePasswordHash(users.UserName, users.Password);
                users.Password = hash;

                db.Users.Add(users);
                db.SaveChanges();
                return RedirectToAction("Index");
            }

            this.SetRoleItems();
            return View(users);
        }

        /* ~~ 省略 ~~ */

        // POST: Users/Edit/5
        // 過多ポスティング攻撃を防止するには、バインド先とする特定のプロパティを有効にしてください。
        // 詳細については、http://go.microsoft.com/fwlink/?LinkId=317598 を参照してください。
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Edit([Bind(Include = "UserId,UserName,Password,RoleId")] Users users)
        {
            if (ModelState.IsValid)
            {
                // パスワードをハッシュ化
                string hash = membershipProvider.GeneratePasswordHash(users.UserName, users.Password);
                users.Password = hash;

                db.Entry(users).State = EntityState.Modified;

                var roleIds = db.UserRole
                    .Where(item => item.UserID == users.UserId)
                    .Select(item => item.RoleID)
                    .ToList();

                foreach (int roleId in users.RoleId)
                {
                    if (db.UserRole
                        .Where(item => item.UserID == users.UserId && item.RoleID == roleId)
                        .Count() > 0)
                    {
                        // 既に登録済み
                        roleIds.Remove(roleId);
                    }
                    else
                    {
                        // 追加
                        var userRole = new UserRole
                        {
                            UserID = users.UserId,
                            RoleID = roleId
                        };
                        db.UserRole.Add(userRole);
                    }
                }

                // 削除されたロールをDBに反映
                foreach(var roleId in roleIds)
                {
                    var item = db.UserRole.Where(ur => ur.UserID == users.UserId && ur.RoleID == roleId).FirstOrDefault();
                    if (item != null)
                    {
                        db.UserRole.Remove(item);
                    }
                }

                db.SaveChanges();
                return RedirectToAction("Index");
            }
            return View(users);
        }

        /* ~~ 省略 ~~ */
    }
}



動作確認

  • administrator でログイン
  • ユーザー追加
    • ユーザーのパスワードがハッシュ化されることを確認
  • ログアウト
  • 追加したユーザーでログイン
  • administratorEdit -> パスワードを設定
    • administrator のパスワードがハッシュ化されることを確認

TODO: 後で削除する と記載されている箇所を削除して、再度デバッグ実行します。

administrator でログインできることを確認します。