この記事は NSSOL Advent Calendar 2018 の6日目の記事です。
概要
業務で最新のASP.NET Coreで認証付きREST APIを実装し、それをSwaggerで利用できるようにしました。
Swaggerを使えるようにするためのページはQiitaにも色々ありますが、認証の話はまとまったものが見当たらなかったので、この場を借りて実装手順をまとめます。
認証はクライアントとサーバ間でJWT認証を使い、サーバではKerberosやLDAPなど、外部システムと通信を行うような構成です。
基本的な流れは以下。Startup.csと仲良くなる必要があります。(正直、結構めんどい)
- プロジェクト作成
- Swagger を追加
- JWT 認証を追加
- Swagger UI の拡張
- 認証用の REST API 実装
環境
- Visual Studio 2017 15.9.2
- .NET Core 2.1
- ASP.NET Core 2.1.1
準備
プロジェクト作成
Visual Studio で空の ASP.NET Core Web APIプロジェクトを作成します。
プロジェクトの作成時に認証方式を選択できますが、今のVisual Studioでは、Azure/AzureAD/Windows認証しか選べないので、一旦は認証無しで作って、後からLDAP等を追加します。
また、簡単化のため、Docker/HTTPSはOFFにしてます。
プロジェクトを作成すると、デフォルトで ValuesController が生成され、以下のAPIが利用可能になっています。今回はこれをそのまま例に使います。
PS Z:\> Invoke-RestMethod -Uri "http://localhost:2192/api/values" -Method GET
value1
value2
Swagger追加
続いてswaggerを追加します。
本家記事に従い、NugetでSwashbuckle.AspNetCore をプロジェクトに追加し、Startup.csを編集します。(usingは省略してます)
public void ConfigureServices(IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
// APIの署名を記載
options.SwaggerDoc("v1", new Info { Title = "Sample API", Version = "v1" });
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "SAMPLE API V1");
});
app.UseMvc();
}
ここまででSwaggerUIからAPIが参照・実行できるようになりました。
ASP.NETにJWT認証を追加する
Startup.csを拡張して、JWTトークンの処理を追加します。JWTトークンについては、Web上に大量の情報があるので、ここでは省略します。
この節の実装を通じて、以下を実現します。
- トークンが指定されていない場合は401 Unauthorizedが出る
- トークンが指定されている場合、クレーム情報をHttpContextに格納する
共通鍵作成
まず、トークンの生成に使う各種設定情報を定義しなければいけないので、以下のクラスを追加します。
共通鍵については、リクエストの度にインスタンスを作るのは効率が悪いので、シングルトンにしています。
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace WebApiAuthSample
{
public static class AuthConfig
{
/// <summary>
/// APIでトークンのAudience (aud) クレームに指定する文字列。
/// トークンの受け取り手(のリスト)を表す。
/// 必要であれば、受け手側で検証を行う。
/// </summary>
public const string ApiJwtAudience = "SampleAudience";
/// <summary>
/// APIでトークンのIssuer (iss) クレームに指定する文字列。
/// 発行者を表す。
/// 必要であれば、受け手側で検証を行う。
/// </summary>
public const string ApiJwtIssuer = "SampleIssur";
/// <summary>
/// APIでトークンのExpiration (exp) クレームに指定する数値。
/// トークンの有効期限(秒)。
/// </suemmary>
public const int ApiJwtExpirationSec = 60 * 60 * 24; //1日
/// <summary>
/// APIで共通鍵の生成に使うパスフレーズ
/// </summary>
private const string ApiSecurityTokenPass = "1234567890QWERTYUIOPASDFGHJKLZXCVBNN";
/// <summary>
/// APIでトークンの生成に使う共通鍵のシングルトン。
/// </summary>
private static SymmetricSecurityKey signingKey;
/// <summary>
/// APIでトークンの生成に使う共通鍵を取得する。
/// </summary>
public static SymmetricSecurityKey ApiJwtSigningKey
{
get
{
if (signingKey == null)
{
byte[] key = Encoding.UTF8.GetBytes(ApiSecurityTokenPass, 0, 32);
signingKey = new SymmetricSecurityKey(key);
}
return signingKey;
}
}
}
}
ウチで実際に使ってるときは、ハードコードでなくappsettings.jsonなどからとってきてます。特に共通鍵の元になるkeyは保存元をしっかり検討した方が良いと思います。
- appsettings.json に入れる(key自体が暗号化されないので精神衛生上よろしくない)
- 乱数をインメモリで(再起動で認証が切れちゃう&冗長化できない)
- MachineKeyやDB、Azure Key Vaultなどからとってくる(実装がちょい増える)
ConfigureService を拡張
ConfigureService に認証を追加します。
REST APIなので、認証失敗したときのエラーもJson形式で返さないといけません。毎回書くとメンドイので、JsonResultを拡張させたクラス CustomJsonResult を作って、共通化してます。詳細は過去記事の ASP.NET CoreでREST APIを作る際のJsonResult拡張クラス を参照してください。
JwtBearerEvents.OnTokenValidated の中で認可処理もやってしまって良いんですが、今回は認可をASP.NET内でやることにして、何もしません。
public void ConfigureServices(IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
// APIの署名を記載
options.SwaggerDoc("v1", new Info { Title = "Sample API", Version = "v1" });
});
#region services.AddAuthentication
services.AddAuthentication(
//既定の認証スキーマ。
JwtBearerDefaults.AuthenticationScheme
)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme,
options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true, // 署名キー検証
IssuerSigningKey = GetApiJwtSigningKey(),
ValidateIssuer = true,
ValidIssuer = "SampleIssur", // iss(issuer)クレーム
ValidateAudience = true, // aud(audience)クレーム
ValidAudience = "SampleAudience",
ValidateLifetime = true, // トークンの有効期限の検証
ClockSkew = TimeSpan.Zero // クライアントとサーバーの間の時刻の設定で許容される最大の時刻のずれ
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
//トークンを正しく取れたときの処理。ログとか出したいときはここに何か書く。
// var token = context.SecurityToken as System.IdentityModel.Tokens.Jwt.JwtSecurityToken;
return Task.FromResult(0);
},
OnChallenge = context =>
{
string errorMessage = context.AuthenticateFailure != null ?
"The Access code is expired or invalid." : // アクセスコードが不正な文字列で復元できない場合
"The access code is required."; // アクセスコードがヘッダに設定されていない場合
// 失敗した際のメッセージをレスポンスに格納する
context.Response.OnStarting(async state =>
{
// アクセスコードがヘッダに設定されていない場合はこちらに入る
await new CustomJsonResult(HttpStatusCode.Unauthorized,
new
{
Type = this.GetType().FullName,
Title = errorMessage,
Instance = context.Request?.Path.Value
}
).SerializeJsonAsync(((JwtBearerChallengeContext)state).Response);
return;
}, context);
return Task.FromResult(0);
},
};
});
#endregion
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}
Configureを拡張
Startup.cs内のConfigureメソッドに以下を追加します。
app.UseAuthentication();
UseSwaggerUIとUseMvcの間にこれを差し込むだけです。こっちは簡単。
ここまでの結果
これでリクエストにJWTトークンが入っていないと401が出るようになりました。
Swagger UIの変更
この状態のSwagger UIにはJWTトークンを入力/送信する機能がありません。
あらゆるAPIが401になるだけで、swaggerの便利さを著しく落としてしまっているので、UIから使えるようにします。
まず、SwaggerUIを拡張するために、以下のクラスを追加します。
リフレクション使っちゃってますが、SwaggerUIを表示したときだけ実行される処理なので、実際のREST API実行へのパフォーマンス的な影響はありません。
using Microsoft.AspNetCore.Authorization;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Linq;
namespace WebApiAuthSample
{
public class AssignJwtSecurityRequirements : IOperationFilter
{
/// <summary>
/// Swagger UI用のフィルタ。
/// Swagger上でAPIを実行する際のJWTトークン認証対応を実現する。
/// </summary>
public void Apply(Operation operation, OperationFilterContext context)
{
if (operation.Security == null)
operation.Security = new List<IDictionary<string, IEnumerable<string>>>();
//AllowAnonymousが付いている場合は、アクセスコードを要求しない
var allowAnonymousAccess = context.MethodInfo.CustomAttributes
.Any(a => a.AttributeType == typeof(AllowAnonymousAttribute));
if (allowAnonymousAccess == false)
{
var oAuthRequirements = new Dictionary<string, IEnumerable<string>>
{
{ "api_key", new List<string>() }
};
operation.Security.Add(oAuthRequirements);
}
}
}
}
続いて、ConfigureSerivcesメソッドの AddSwaggerGen の中に、以下を追加します。
//トークン認証用のUIを追加する
options.AddSecurityDefinition("api_key", new ApiKeyScheme()
{
Name = "Authorization",
In = "header",
Type = "apiKey", //この指定が必須。https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/124
Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\""
});
// 入力したトークンをリクエストに含めるためのフィルタを追加
options.OperationFilter<AssignJwtSecurityRequirements>();
これでAuthorizeボタンが表示され、トークンを埋め込めるようになりました。
また、認証しないと使えないAPIは、右側に鍵のマークが表示されています。
このAuthorizeボタンか鍵マークのどちらかをクリックすると、トークンを入力するためのUIが表示されます。
トークンの発行処理実装
ユーザ名&パスワードで認証して、成功したらJWTトークンを発行する処理を実装します。
トークン発行機能をAPIに持たせる(=パスワードを送らせる)となると、HTTPS化は必須な気がしますが、今回は省略してます。
新しいAPI "POST /api/Account/Login" を作って、トークンを発行可能にします。コードでは、認証&認可の処理はダミーを入れています。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using System.Net;
namespace WebApiAuthSample.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
[HttpPost]
[AllowAnonymous] //ログイン機能自体は認証無しで使えるようにする
public async Task<IActionResult> Login([FromBody] LoginInputModel model)
{
//認証
bool authResult = (await AuthenticateAsync(model.UserName, model.Password));
if (authResult == false)
{
return new CustomJsonResult(HttpStatusCode.BadRequest, "User name or password is incorrect.");
}
//認可処理してトークンを作成
var token = GenerateToken(model.UserName);
var result = new
{
Token = token,
UserName = model.UserName,
ExpiresIn = AuthConfig.ApiJwtExpirationSec
};
return new CustomJsonResult(HttpStatusCode.OK, result);
}
/// <summary>
/// 認証処理
/// </summary>
private async Task<bool> AuthenticateAsync(string userName, string password)
{
//何かしらの認証処理(Kerberos認証したり、LDAPしたり、よしなに)
await Task.CompletedTask;
//今はユーザ名/パスワードがhoge/hugaならtrue
return userName == "hoge" && password == "huga";
}
/// <summary>
/// 認可処理
/// </summary>
private List<Claim> Authorize(string userName)
{
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Name, userName));
//何かしらの認可処理(グループ付けたり、ロール付けたり、よしなに)
string groupId = "piyo";
claims.Add(new Claim(ClaimTypes.GroupSid, groupId));
return claims;
}
/// <summary>
/// JWTトークンを発行する
/// </summary>
/// <param name="userName">ユーザ名</param>
/// <param name="expiresIn">有効期限(秒)</param>
private string GenerateToken(string userName)
{
//トークンに含めるクレームの入れ物
List<Claim> claims = Authorize(userName);
//現在時刻をepochタイムスタンプに変更
var now = DateTime.UtcNow;
long epochTime = (long)Math.Round((
now.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)
).TotalSeconds);
//JWT ID(トークン生成ごとに一意になるようなトークンのID)。ランダムなGUIDを採用する。
string jwtId = Guid.NewGuid().ToString();
claims.Add(new Claim(JwtRegisteredClaimNames.Sub, userName));
claims.Add(new Claim(JwtRegisteredClaimNames.Jti, jwtId));
claims.Add(new Claim(JwtRegisteredClaimNames.Iat, epochTime.ToString(), ClaimValueTypes.Integer64));
//期限が切れる時刻
DateTime expireDate = now + TimeSpan.FromSeconds(AuthConfig.ApiJwtExpirationSec);
// Json Web Tokenを生成
var jwt = new JwtSecurityToken(
AuthConfig.ApiJwtIssuer, //発行者(iss)
AuthConfig.ApiJwtAudience, //トークンの受け取り手(のリスト)
claims, //付与するクレーム(sub,jti,iat)
now, //開始時刻(nbf)(not before = これより早い時間のトークンは処理しない)
expireDate, //期限(exp)
new SigningCredentials(AuthConfig.ApiJwtSigningKey, SecurityAlgorithms.HmacSha256) //署名に使うCredential
);
//トークンを作成(トークンは上記クレームをBase64エンコードしたものに署名をつけただけ)
var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
return encodedJwt;
}
/// <summary>
/// ログインの入力モデル
/// </summary>
public class LoginInputModel
{
/// <summary>ユーザ名</summary>
public string UserName { get; set; }
/// <summary>パスワード</summary>
public string Password { get; set; }
}
}
}
これで完成です!
Swagger上にLogin用のAPIが現れ、トークンを取得できるようになります。
GoogleとかTwitterでは認証失敗しても200とか204とか返しますが、今回は400 BadRequestが出ます。適宜API仕様を決めていただければと。
Swaggerで認証付きのAPIを使ってみる
実際に、SwaggerからJWTトークンを使って、認証必須なAPIを実行してみます。
Authorizeボタンを押して、トークンを「Bearer トークン」のフォーマットで入力します。
そのあとで、適当なAPIを叩くと、リクエストヘッダにトークンが挿入され、認証済みとして実行が可能です。
変な文字列をトークンに入れても、401 Unauthorizedではじかれます。
ASP.NET Core 内でトークン内のクレーム情報を参照する
トークンが設定されている際は、HttpContextにクレーム情報が自動で格納されるようになっています。このクレームを確認したい場合は、以下のコードで取得可能です。
var identity = HttpContext.User.Identity as ClaimsIdentity;
identity.Claims.FirstOrDefault(c => c.Type == ClaimTypes.GroupSid).Value
適宜認可用のFilterを作るよろし。
ソースコード
この記事で作ったプロジェクトのソースコードは、GitHubに置いています。
終わりに
単にSwaggerを使うだけなら簡単なのに、認証付きだとそれなりに実装手順が多くてツラい
ここまでの手順で、認証付きのAPI開発にSwaggerが使えるようになります。
API開発する上でSwaggerはめっちゃ便利なので、うまく活用しましょう。
まだ改良点はいろいろありますが、時間と心の余裕があったら追加で記事書きます。
- HTTPS化
- Swagger上での情報整備(SwaggerをAPIドキュメント化)
- WebUIとの共存
- エラーハンドリング
- ログ出し
それなりに大きいシステム作ると認証は避けて通れないと思うので、誰かのお役に立てば幸いです。