シングルサインオン機能を備えたToDoリストをAzureに構築してみた

# はじめに
SAML認証でシングルサインオンできるCRUD処理アプリをAzureAppServiceにデプロイしてみた！
AzureAD, AzureAppService, AzureSQLDatabaseという3つのサービスを使っています。
今どき企業向けのアプリケーションでID管理をアプリ個別に行うことなんてほぼないと思います。ので、シングルサインオンのソリューションとしておそらく最も（？）一般的なAzureADでCRUD処理アプリケーションを認証させてみました。
やりたいことは↓こんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/d6ce7bfa-afe2-35f0-7d67-f4fea387a2bb.png)

# 作り方
## プロジェクトの作成とSAML認証の実装
↓リンクを参照（テンプレートはASP.Net Core WebAppとする。）
https://qiita.com/Auggie_2345/items/7cef8250866278ec2572

## CRUD処理部分の作成
↓下記のリンクの各章をそれぞれ参照(リンクのテンプレートはASP.Net Core MVCなので、上記で作ったテンプレートとは若干違うので注意)
https://qiita.com/Auggie_2345/items/5b8803a2bf6249ffac77

* データベースとテーブルの作成
* Nugetパッケージの入手
* Visual StudioとSQLServerの接続
* リバーススキャフォールディング(モデルとコンテキスト作成）
* スキャフォールディング(ビューとコントローラー作成)

出来上がったら、ソリューションの構成はこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/de3f06a9-d835-e3c2-e956-159defd25dda.png)

以下コードです。（スキャフォールディングされる部分は割愛）
(SAML部分は↓リンク参照)
https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14

<details><summary>長いのでおりたたみました</summary><div>

```C#:Program.cs
using ITfoxtec.Identity.Saml2;
using ITfoxtec.Identity.Saml2.Schemas.Metadata;
using ITfoxtec.Identity.Saml2.MvcCore.Configuration;
using Microsoft.EntityFrameworkCore;
using ToDoList_2.Context;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ToDoListContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("ToDoList") ?? throw new InvalidOperationException("Connection string 'ToDoList' not found.")));
// Add services to the container.
builder.Services.AddRazorPages();

builder.Services.Configure<Saml2Configuration>(builder.Configuration.GetSection("Saml2"));
builder.Services.Configure<Saml2Configuration>(saml2Configuration =>
{
    saml2Configuration.AllowedAudienceUris.Add(saml2Configuration.Issuer);

    var entityDescriptor = new EntityDescriptor();
    entityDescriptor.ReadIdPSsoDescriptorFromUrl(new Uri(builder.Configuration["Saml2:IdPMetadata"]));
    if (entityDescriptor.IdPSsoDescriptor != null)
    {
        saml2Configuration.SingleSignOnDestination = entityDescriptor.IdPSsoDescriptor.SingleSignOnServices.First().Location;
        saml2Configuration.SignatureValidationCertificates.AddRange(entityDescriptor.IdPSsoDescriptor.SigningCertificates);
    }
    else
    {
        throw new Exception("IdPSsoDescriptor not loaded from metadata.");
    }
});
builder.Services.AddSaml2();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.MapDefaultControllerRoute();
app.UseSaml2();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

```

```C#:Context/ToDoListContext.cs
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using ToDoList_2.Models;

namespace ToDoList_2.Context;

public partial class ToDoListContext : DbContext
{
    public ToDoListContext()
    {
    }

    public ToDoListContext(DbContextOptions<ToDoListContext> options)
        : base(options)
    {
    }

    public virtual DbSet<ToDo> ToDos { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlServer("Name=ConnectionStrings:ToDoList");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ToDo>(entity =>
        {
            entity.ToTable("ToDo");

            entity.Property(e => e.Id)
                .HasDefaultValueSql("(newid())")
                .HasColumnName("id");
            entity.Property(e => e.DueDate).HasColumnType("datetime");
            entity.Property(e => e.ToDo1)
                .HasMaxLength(50)
                .HasColumnName("ToDo");
        });

        OnModelCreatingPartial(modelBuilder);
    }

    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

```

```C#:Controllers/AuthController.cs
using ITfoxtec.Identity.Saml2;
using ITfoxtec.Identity.Saml2.Schemas;
using ITfoxtec.Identity.Saml2.MvcCore;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ToDoList_2.Identity;
using Microsoft.Extensions.Options;
using System.Security.Authentication;
using ToDoList_2.Pages;

namespace ToDoList_2.Controllers
{
    [AllowAnonymous]
    [Route("Auth")]
    public class AuthController : Controller
    {
        const string relayStateReturnUrl = "ReturnUrl";
        private readonly Saml2Configuration config;

        public AuthController(IOptions<Saml2Configuration> configAccessor)
        {
            config = configAccessor.Value;
        }

        [Route("Login")]
        public IActionResult Login(string returnUrl = null)
        {
            var binding = new Saml2RedirectBinding();
            binding.SetRelayStateQuery(new Dictionary<string, string> { { relayStateReturnUrl, returnUrl ?? Url.Content("~/") } });

            return binding.Bind(new Saml2AuthnRequest(config)).ToActionResult();
        }

        [Route("AssertionConsumerService")]
        public async Task<IActionResult> AssertionConsumerService()
        {
            var binding = new Saml2PostBinding();
            var saml2AuthnResponse = new Saml2AuthnResponse(config);

            binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse);
            if (saml2AuthnResponse.Status != Saml2StatusCodes.Success)
            {
                throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}");
            }
            binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse);
            await saml2AuthnResponse.CreateSession(HttpContext, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal));

            var relayStateQuery = binding.GetRelayStateQuery();
            var returnUrl = relayStateQuery.ContainsKey(relayStateReturnUrl) ? relayStateQuery[relayStateReturnUrl] : Url.Content("~/");
            return Redirect(returnUrl);
        }

        [HttpPost("Logout")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Logout()
        {
            if (!User.Identity.IsAuthenticated)
            {
                return Redirect(Url.Content("~/"));
            }

            var binding = new Saml2PostBinding();
            var saml2LogoutRequest = await new Saml2LogoutRequest(config, User).DeleteSession(HttpContext);
            return Redirect("~/");
        }
    }
}
```

```C#:Pages/Index.cshtml
@page
@model ToDoList_2.Pages.ToDoList.IndexModel

@{
    ViewData["Title"] = "Index";
}

@if (((System.Security.Claims.ClaimsIdentity)User.Identity).IsAuthenticated) 

{ 

<h1>Index</h1>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.ToDo[0].ToDo1)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.ToDo[0].DueDate)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.ToDo) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.ToDo1)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.DueDate)
            </td>
            <td>
                <a asp-page="./Edit" asp-route-id="@item.Id">Edit</a> |
                <a asp-page="./Details" asp-route-id="@item.Id">Details</a> |
                <a asp-page="./Delete" asp-route-id="@item.Id">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>
}else
{
        <h1>ログインしてください</h1>
}
```

```C#:Pages/Index.cshtml.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using ToDoList_2.Context;
using ToDoList_2.Controllers;
using ToDoList_2.Models;

namespace ToDoList_2.Pages.ToDoList
{
    public class IndexModel : PageModel
    {
        private readonly ToDoList_2.Context.ToDoListContext _context;

        public IndexModel(ToDoList_2.Context.ToDoListContext context)
        {
            _context = context;
        }

        public IList<ToDo> ToDo { get;set; } = default!;

        public async Task OnGetAsync()
        {
            if(((System.Security.Claims.ClaimsIdentity)User.Identity).IsAuthenticated)
            {
                if (_context.ToDos != null)
                {
                    ToDo = await _context.ToDos.ToListAsync();
                }
            }
        }
    }
}

```

```C#:Pages/Shared/_Layout.cshtml
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - ToDoList_2</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/ToDoList_2.styles.css" asp-append-version="true" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
            <div class="container">
                <a class="navbar-brand" asp-area="" asp-page="/Index">ToDoList_2</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                        aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
                    <ul class="navbar-nav flex-grow-1">

                        @if (((System.Security.Claims.ClaimsIdentity)User.Identity).IsAuthenticated)
                        {
                            <li>
                                @if (User.Identity.Name != null)
                                {
                                            <span class="navbar-text">Hello, @User.Identity.Name</span>
                                }
                                else
                                {
                                            <span class="navbar-text">Hello</span>
                                }
                            </li>
                        }
                        else
                        {
                            <li class="nav-item">
                                <a class="nav-link text-dark" asp-controller="Auth" asp-action="Login">Login</a>
                            </li>
                        }
                    </ul>
                </div>

            </div>
        </nav>
    </header>
    <div class="container">
        <main role="main" class="pb-3">
            @RenderBody()
        </main>
    </div>

    <footer class="border-top footer text-muted">
        <div class="container">
            &copy; 2023 - ToDoList_2 - 
        </div>
    </footer>

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>

    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>
```

```appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
     "ToDoList": "接続文字列;"
  },
  "Saml2": {
    "IdPMetadata": "IdPメタデータ",
    "Issuer": "Identifier (Entity ID)",
    "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
    "CertificateValidationMode": "None",
    "RevocationMode": "NoCheck"
  }
}
```

</div></details>

ソースができたら、GitHubにリモートリポジトリを作ってPushしておきます。
プライベートリポジトリの方がベター。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/9a9bcda0-9e60-1c00-3dc3-8fabce1f1a31.png)

## Azure側の設定

### AppService
AppService(WebApp)を作成します。OSはWindows、プランは無料のF1で良いです。

↓のリンクを参考にして、GitHub Actionsの設定をやっておきます。
https://qiita.com/Futo_Horio/items/cd39976604692d6de001

↓今回は動確用のアプリなので、ローカルPCからのみアクセスを可能となるようにIPアドレス制限を設けます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/e9ab2475-d421-0ac4-96cb-0e5a131975bc.png)


### Azure ADの設定
↓リンクを参照
https://qiita.com/Auggie_2345/items/7cef8250866278ec2572#azure-ad%E8%A8%AD%E5%AE%9A

### AzureSQLDatabase
↓リンクを参照
https://qiita.com/Auggie_2345/items/fddad871aa7112aac444#azure-sql-database%E3%81%AE%E4%BD%9C%E6%88%90

AppServiceからAzureSQLDBにつなぐため、Firewallのルールに入れてあげる必要があるので注意↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/7f23ed87-f672-b3aa-d596-abc9b649ac2f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/0aa43b92-b47b-be4c-650d-658ca599e439.png)

## デプロイ
GitHub Actionsの設定ができていれば、mainにPushするだけで自動でデプロイされます。

## 動確
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/1b453519-281d-0dd1-ffad-20d2ae4671bb.png)

↓ADに登録したテストユーザーを選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/03f3b904-92ff-32fa-d0ce-f02abb8de647.png)

Action Requiredは"Ask Later"を選択

↓アプリにログインできました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378457/10705d8b-51f2-4f96-4e77-3bc798462821.png)


# 参考
https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14
https://qiita.com/Futo_Horio/items/cd39976604692d6de001