ASPNetCoreWebAPIに権限付きの認証を追加する方法


React+ASP.Net Core で作成しているアプリにログイン機能を実装しようと思い、WebAPI に対して Cookie 認証を追加する方法を調べました。最終的には一般ユーザーと管理者ユーザーで権限を分けて管理者のユーザーしかアクセスできない API を作成します。

## 作業概要

基本的には下記の手順をもとに進めていきます。

【認証追加】

https://learn.microsoft.com/ja-jp/aspnet/core/security/authentication/identity-api-authorization?view=aspnetcore-9.0

【権限の追加】

https://learn.microsoft.com/ja-jp/aspnet/core/security/authorization/secure-data?view=aspnetcore-9.0

ただし権限の追加について、公式ページでは Razor ページで説明されているため WebAPI に変更します。

## 認証の追加

### Nuget パッケージのインストール

下記パッケージをインストールします。

- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SqlServe(使用しているプロパイダーに合わせます)

### 認証に使用する DB テーブルの用意

まず初めに認証に使用する DB を作成します。作成には`IdentityDbContext<TUser>`を継承したクラスを作成します。

```Csharp:ApplicationIdentityDbContext.cs
public class ApplicationIdentityDbContext : IdentityDbContext<IdentityUser>
{
    public ApplicationIdentityDbContext(DbContextOptions<ApplicationIdentityDbContext> options) :
    base(options)
    { }
}
```

そして作成した DbContext を`Program.cs`でサービス登録を追加します。
すでにアプリで DB を利用している場合、接続文字列は同じにしてもよいし、別でもよいです。

同じにした場合は既存の DB に認証用のテーブルが追加されます。
別にした場合は新しく認証用の DB テーブルが作成されるイメージです。

```Csharp
builder.Services.AddDbContext<ApplicationIdentityDbContext>(options =>
    options.UseSqlServer(connection));
```

最後にマイグレーションを実施します。
アプリで別の DBContext を使用している場合は DbContext ごとにマイグレーションが必要になるため実行するコマンドに注意してください。

```powershell
dotnet ef migrations add Add_Identfy -c "ApplicationIdentityDbContext"
dotnet ef database update -c "ApplicationIdentityDbContext"
```

VSCode 環境でターミナルから更新する場合は`-c`に更新したい DBContext を指定します。

### 認証サービスを追加

`Program.cs`に認証サービスを追加します。
※必要な箇所のみ抜粋します。

```CSharp
// Add Identity
builder.Services.AddDefaultIdentity<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationIdentityDbContext>()
    .AddDefaultTokenProviders();

// すべてのリクエストに対して認証を要求する
builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

// Cookieの設定
builder.Services.ConfigureApplicationCookie(options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(30); // 30分でCookieの有効期限が切れます
});

```

すべての API に対して認証を必要とするように設定します。認証が不要な API は個別に設定が可能です。
今回は API が追加された際に漏れがないようにこのようにしましたが、認証が必要な API だけ個別に設定することも可能です。

実際にログインを行う API も追加します。ASP.Net Core が基本的な API を一気に追加するメソッドを用意してくれているためそれを使用します。

```Csharp
app.MapGroup("api").MapIdentityApi<IdentityUser>().AllowAnonymous();
app.MapPost("api/logout", async (SignInManager<IdentityUser> signInManager,
    [FromBody] object empty) =>
{
    if (empty != null)
    {
        await signInManager.SignOutAsync();
        return Results.Ok();
    }
    return Results.Unauthorized();
});
```

`.MapGroup("api")`を追加しているのは URL を「~localhost:300/api/login」のような形で API 配下にまとめたかったためです。任意です。 `.AllowAnonymous();`はこのメソッドで追加する API が認証不要でアクセスできることを設定します。こちらは必ず必要です。

またデフォルトではログアウトは提供されないため、手動で定義しています。

## 権限の追加

ここまででログイン機能に必要な認証は設定できました。管理者など権限わけが不要ならこのままで利用可能です。
続いて通常ユーザーと管理者ユーザーの追加をします。

### ロールサービスの追加

認証機能にロールベースの機能を追加します。

```diff_C_Sharp
// Add Identity
builder.Services.AddDefaultIdentity<IdentityUser>()
+   .AddRoles<IdentityRole>() // Separate AddRoles
    .AddEntityFrameworkStores<ApplicationIdentityDbContext>()
    .AddDefaultTokenProviders();
```

これでロールを使用することが可能です。

ロールは`AspNetRoles`テーブルと`AspNetUserRoles`テーブルで管理されます。
ロール自体は文字列で管理されているため Constants クラスなどを作成してアプリ全体で統一するのが吉です(今回はべた書きします)。

また未認証の状態で保護された API にアクセスするとログインページに遷移するようにデフォルトで設定されています。
API としては`401 Unauthorized`を期待するため設定を追加します。

```csharp
// ログインページへのリダイレクトを401に変更
builder.Services.ConfigureApplicationCookie(options =>
{
    options.Events.OnRedirectToLogin = context =>
    {
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return Task.CompletedTask;
    };
});
```

### API の保護

コントローラーベースの API を追加して権限ごとに保護します。

```csharp
// 未認証でもアクセス可能
[HttpGet("allUser")]
[AllowAnonymous]
public string AllUser()
{
    return "Everyone can see this!";
}

// 認証済みでアクセス可能
[HttpGet("user")]
public string User()
{
    return "You're a user!";
}

// Admin権限で認証した場合のみ可能
[HttpGet("admin")]
[Authorize(Roles = "Admin")]
public string Admin()
{
    return "You're an admin!";
}
```

## 実装できているかの確認

最後に確認用に実際に通常の権限ユーザーと管理者権限のユーザーを登録します。

データ投入用の`SeedData`クラスを追加します。

```Csharp
public class SeedData
{
    public static async Task Initialize(IServiceProvider serviceProvider)
    {
        var testUserPw = "Pass@word1";// デモ用のパスワード
        using (var context = new ApplicationIdentityDbContext(
            serviceProvider.GetRequiredService<DbContextOptions<ApplicationIdentityDbContext>>()))
        {
            var adminID = await EnsureUser(serviceProvider, testUserPw, "admin@contoso.com");
            await EnsureRole(serviceProvider, adminID, "Admin");

            var managerID = await EnsureUser(serviceProvider, testUserPw, "user@contoso.com");
            await EnsureRole(serviceProvider, managerID, "User");
        }
    }

    private static async Task<string> EnsureUser(IServiceProvider serviceProvider, string testUserPw, string UserName)
    {
        var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

        var user = await userManager.FindByNameAsync(UserName);
        if (user == null)
        {
            user = new IdentityUser
            {
                UserName = UserName,
                EmailConfirmed = true
            };
            await userManager.CreateAsync(user, testUserPw);
        }

        if (user == null)
        {
            throw new Exception("The password is probably not strong enough!");
        }

        return user.Id;
    }

    private static async Task<IdentityResult> EnsureRole(IServiceProvider serviceProvider, string uid, string role)
    {
        var roleManager = serviceProvider.GetService<RoleManager<IdentityRole>>();

        if (roleManager == null)
        {
            throw new Exception("roleManager null");
        }

        IdentityResult IR;
        if (!await roleManager.RoleExistsAsync(role))
        {
            IR = await roleManager.CreateAsync(new IdentityRole(role));
        }

        var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

        if (userManager == null)
        {
            throw new Exception("userManager is null");
        }

        var user = await userManager.FindByIdAsync(uid);

        if (user == null)
        {
            throw new Exception("The testUserPw password was probably not strong enough!");
        }

        IR = await userManager.AddToRoleAsync(user, role);

        return IR;
    }
}
```

アプリ初期化時に処理を実行します。

```Csharp:Program.cs
var app = builder.Build();

// テスト用のユーザーを作成
using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    var context = services.GetRequiredService<ApplicationIdentityDbContext>();
    context.Database.Migrate();
    await SeedData.Initialize(services);
}
```

これでユーザー名が`admin@contoso.com`と`user@contoso.com`が使用可能になります。パスワードはどちらも`Pass@word1`です。

:::note
※確認用にユーザーを追加しました。実際のシステムでは登録処理も実装が必要です。
:::

このままアプリを起動して Swagger などで`api/login`などにアクセスして確認すれば適切に認証ができていることがわかると思います。

## おわりに

ASP.Net Core の WebAPI に権限付きの認証を追加する方法をまとめました。

.Net が提供している機能を組み合わせることで簡単に実装できて、やはりこの辺はさすが Microsoft だなーと思いました。

この記事が皆様のコーディングライフの助けになれば幸いです。

## 参考

https://learn.microsoft.com/ja-jp/aspnet/core/security/authentication/identity-api-authorization?view=aspnetcore-9.0

https://learn.microsoft.com/ja-jp/aspnet/core/security/authorization/secure-data?view=aspnetcore-9.0