ASP.NET Core MVC: 認証機能を作る

 前回まででブログを作る前の基本的なものはそろってきた。ブログといえば記事を書いて、それを一般に公開するものだ。ブログを書く管理者がいるので、そいつに権限を与える認証機能を実装しなければならないのでまずそこから。

 まず認証に使うページのコントローラとビューを、認証機能なしの状態で用意する。↓の三つのファイルをプロジェクト内に用意。ちなみに管理者なのでAdminという単語を関係のあるところに使おうとするところだが、Adminをパスにおくとやたら不正なアクセスがある。なのでちょいと意味が違う気がするがMasterという単語を使っている。
/Controllers/MasterController.cs

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using tetsujin.Models;

namespace tetsujin.Controllers
{
[Route("Master")]
public class MasterController : Controller
{

[Route("")]
public IActionResult Index()
{
return View();
}

}
}


/Views/Master/Index.cshtml

@{
Layout = "/Views/Shared/_MasterLayout.cshtml";
ViewBag.Title = "Admin Page";
}
管理者用だよ


/Views/Shared/_MasterLayout.cshtml

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - tetsujin</title>
</head>
<body>
@RenderBody()
</body>
</html>


 上記三つのファイルを加えた状態でデバッグを開始する。https://localhost/Masterへアクセスするとコンテンツが表示されてしまうので、これから認証機能を実装する。

 コントローラには属性でフィルタをつけることができる。フィルタには処理を定義できる。これを使ってMasterController内のアクションすべてに、認証がとおっていないユーザにコンテンツを見せないようにする。

 まずフィルタを作成。
/Scripts/AuthorizationFilter.cs

using Microsoft.AspNetCore.Mvc.Filters;
using System;
using tetsujin.Models;

namespace tetsujin.Filters
{

public class AuthorizationFilter : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var token = context.HttpContext.Request.Cookies[Session.SESSION_COOKIE];
if (!Session.isAuthorized(token))
{
context.HttpContext.Response.StatusCode = 403;
throw new ArgumentException("Forbidden access.");
}
}
}
}


 フィルタを定義したのでMasterControllerに付加してする。
/Controllers/MasterController.cs

namespace tetsujin.Controllers
{
[AuthorizationFilter]
[Route("Master")]
public class MasterController : Controller
{
[Route("")]
public IActionResult Index()
{
return View();
}

}
}


 管理者ユーザモデルの定義。
/Models/Master.cs

using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;

namespace tetsujin.Models
{
public class Master
{
public const string CollectionName = "Master";

[BsonId]
[BsonRepresentation(BsonType.String)]
public string Id { get; set; }

[BsonElement("pw")]
[BsonRepresentation(BsonType.String)]
[BsonRequired]
public string Password { get; set; }
}
}


 セッションのモデルの定義。
/Models/Session.cs

using MangoFramework;
using Microsoft.AspNetCore.Http;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;

namespace tetsujin.Models
{
[MongoDoc]
public class Session
{
public const string SESSION_COOKIE = "markofcain";
private static string hashkey = "";
public static string Hashkey
{
set { Session.hashkey = value; }
get
{
if (Session.hashkey == "" || Session.hashkey == null)
{
throw new ArgumentNullException("Hashkey for session isn't given. Set hashkey in environment value as 'HASHKEY'.");
}
return Session.hashkey;
}
}

public const string CollectionName = "Session";

[BsonId]
[BsonRepresentation(BsonType.String)]
public string Id { get; set; }

[BsonElement("createdAt")]
[BsonRepresentation(BsonType.DateTime)]
[BsonRequired]
public DateTime CreatedAt { get; set; }

public static List<CreateIndexModel<BsonDocument>> indexModels = new List<CreateIndexModel<BsonDocument>>()
{
new CreateIndexModel<BsonDocument>(
new IndexKeysDefinitionBuilder<BsonDocument>().Ascending(new StringFieldDefinition<BsonDocument>("createdAt")),
new CreateIndexOptions(){ ExpireAfter = TimeSpan.FromDays(10) }
)
};

/// <summary>
/// ログインしているかの状態を返す
/// </summary>
/// <param name="token">セッショントークン</param>
/// <returns>bool</returns>
public static bool isAuthorized(string token)
{
if (token == null)
{
return false;
}
var collection = DbConnection.Db.GetCollection<Session>(Session.CollectionName);
var sessionManager = collection.Find<Session>(d => d.Id == token)
.FirstOrDefault<Session>();
var isLogin = (sessionManager == null) ? false : true;
return isLogin;
}


/// <summary>
/// ランダムトークンを取得
/// </summary>
/// <returns>ランダムトークン</returns>
private static string GetToken()
{
var rng = RandomNumberGenerator.Create();
var buff = new byte[25];
rng.GetBytes(buff);
return Convert.ToBase64String(buff);
}

/// <summary>
/// ユーザIDとパスワードからハッシュを作成する
/// </summary>
/// <param name="userName">ユーザID</param>
/// <param name="pw">パスワード</param>
/// <returns>ハッシュ</returns>
private static string GetSHA256(string userName, string pw)
{
//HMAC-SHA1を計算する文字列
var s = $"{userName}-{pw}";
//キーとする文字列
var key = Session.Hashkey;
if (String.IsNullOrEmpty(key))
{
throw new ArgumentException("Couldn't get Hashkey.");
}

//文字列をバイト型配列に変換する
byte[] data = System.Text.Encoding.UTF8.GetBytes(s);
byte[] keyData = System.Text.Encoding.UTF8.GetBytes(key);


byte[] bs;
//HMACSHA1オブジェクトの作成
using (var hmac = new HMACSHA256(keyData))
{
//ハッシュ値を計算
bs = hmac.ComputeHash(data);
}

//byte型配列を16進数に変換
var result = BitConverter.ToString(bs).ToLower().Replace("-", "");

return result;
}


/// <summary>
/// ログインを実行する
/// </summary>
/// <param name="id">ユーザID</param>
/// <param name="pw">パスワード</param>
/// <param name="cookies">クッキー</param>
/// <returns>ログインの成否</returns>
public static bool Login(string id, string pw, IResponseCookies cookies)
{
Console.WriteLine(id);
var userCollection = DbConnection.Db.GetCollection<Master>(Master.CollectionName);
var filter = Builders<Master>.Filter.Eq("_id", id);
var master = userCollection.Find<Master>(filter).FirstOrDefault<Master>();
if (master == null) // ユーザが登録されていない場合
{
Console.WriteLine("no user");
return false;
}
else // ユーザが登録されていた場合
{
// パスワードをハッシュ化
var sha256 = GetSHA256(master.Id, pw);

// パスワードの一致確認
if (sha256 != master.Password)
{
Console.WriteLine($"not match: ${sha256}");
return false;
}
else
{
// トークンを使ってセッションを開始
var token = GetToken();
var collection = DbConnection.Db.GetCollection<Session>(Session.CollectionName);
collection.InsertOne(new Session
{
Id = token,
CreatedAt = DateTime.Now
});

// cookieにsecure属性を付与
var cookieOption = new CookieOptions()
{
Secure = true
};
cookies.Append(SESSION_COOKIE, token, cookieOption);

return true;
}
}
}


}
}


 あとログイン機能の実装。コントローラから。
/Controllers/AuthorizationController.cs

using Microsoft.AspNetCore.Mvc;
using tetsujin.Models;

namespace tetsujin.Controllers
{
[Route("Auth")]
public class AuthorizationController : Controller
{
[HttpGet]
[Route("Login")]
public IActionResult Login()
{
return View();
}

[HttpPost]
[Route("Login")]
public RedirectResult LoginAuth()
{
var id = Request.Form["_id"];
var password = Request.Form["password"];
var isAuthorized = Session.Login(id, password, Response.Cookies);

if (isAuthorized)
{
return Redirect("/Master");
}
else
{
return Redirect("/Auth/Login");
}
}
}
}


 続いてログインページのビュー(最低限)。
/Views/Authorization/Login.cshtml

@{
Layout = "/Views/Shared/_Layout.cshtml";
ViewData["Title"] = "Login";
}

<form action="/Auth/Login" method="post">
<input type="text" name="_id" /><br />
<input type="password" name="password" /><br />
<input type="submit" name="enter" value="Login" />
</form>


 あとはちょっと設定。
 まずMongoDBのコレクションをよしなにやるライブラリを入れる。パッケージマネージャコンソールでコマンド。
Install-Package MangoFramework



 Startup.csのメソッドStartupに追記がある。
Startup.cs

public Startup(IConfiguration configuration)
{
Configuration = configuration;

// DB接続確立
var dbName = "blog";
DbConnection.Connect(configuration.GetValue<string>("MONGO_CONNECTION"), dbName);

// 宣言されたモデルからDBにコレクションを作る
MongoInitializer.Run(DbConnection.Db, "tetsujin");

// ユーザパスワードのハッシュキー
Session.Hashkey = configuration.GetValue<string>("HASHKEY");
}


 appsettings.json
appsettings.json

{
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Warning"
}
},
"MONGO_CONNECTION": "mongodb://10.0.75.1",
"HASHKEY": "notmattertome"
}


 VisualStudioでデバッグを開始する。その状態でコンソールなどを開いてMongoDBに接続して、ブログのデータベースのMasterコレクションに、デバッグ環境用の管理者ユーザのドキュメントを一件入れる。ぼくのデバッグ環境では下記。
db.Master.insert({"_id":"testuser", "pw":"0d6a78622e8c92da07a2aa0e34ad7656a644481b849b0b75dc31358a59708251"})


↑のドキュメントは下記情報で、このアプリのハッシュ化方法のもとにパスワードをハッシュ化したもの。
ID: testuser
PW: password
Hashkey: notmattertome

 認証を通していないブラウザでhttps://localhost/Masterへアクセスする。エラーでコンテンツが見られない。



 https://localhost/Auth/Loginにアクセスすればログインフォームが出てくるので、↑の通りにID、パスワードを入れる。成功すれば管理者ページがみられる。


今回までのコミット
※追記
/Controllers/MasterController.csにおいてAuthorizationFilterをつける場所を間違えていた。AuthorizationFilterはアクションではなくコントローラクラスにつける。

comment: 0