1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-06-17

はじめに

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

image.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

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

長いのでおりたたみました
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();

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);
}

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("~/");
        }
    }
}
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>
}
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();
                }
            }
        }
    }
}

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"
  }
}

ソースができたら、GitHubにリモートリポジトリを作ってPushしておきます。
プライベートリポジトリの方がベター。
image.png

Azure側の設定

AppService

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

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

↓今回は動確用のアプリなので、ローカルPCからのみアクセスを可能となるようにIPアドレス制限を設けます。
image.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

image.png

デプロイ

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

動確

image.png

↓ADに登録したテストユーザーを選択
image.png

Action Requiredは"Ask Later"を選択

↓アプリにログインできました!
image.png

参考

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?