はじめに
SAML認証でシングルサインオンできるCRUD処理アプリをAzureAppServiceにデプロイしてみた!
AzureAD, AzureAppService, AzureSQLDatabaseという3つのサービスを使っています。
今どき企業向けのアプリケーションでID管理をアプリ個別に行うことなんてほぼないと思います。ので、シングルサインオンのソリューションとしておそらく最も(?)一般的なAzureADでCRUD処理アプリケーションを認証させてみました。
やりたいことは↓こんな感じです。
作り方
プロジェクトの作成と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の接続
- リバーススキャフォールディング(モデルとコンテキスト作成)
- スキャフォールディング(ビューとコントローラー作成)
以下コードです。(スキャフォールディングされる部分は割愛)
(SAML部分は↓リンク参照)
https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14
長いのでおりたたみました
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();
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);
}
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("~/");
}
}
}
@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>
}
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();
}
}
}
}
}
<!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">
© 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>
{
"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しておきます。
プライベートリポジトリの方がベター。
Azure側の設定
AppService
AppService(WebApp)を作成します。OSはWindows、プランは無料のF1で良いです。
↓のリンクを参考にして、GitHub Actionsの設定をやっておきます。
https://qiita.com/Futo_Horio/items/cd39976604692d6de001
↓今回は動確用のアプリなので、ローカルPCからのみアクセスを可能となるようにIPアドレス制限を設けます。
Azure ADの設定
↓リンクを参照
https://qiita.com/Auggie_2345/items/7cef8250866278ec2572#azure-ad%E8%A8%AD%E5%AE%9A
AzureSQLDatabase
AppServiceからAzureSQLDBにつなぐため、Firewallのルールに入れてあげる必要があるので注意↓
デプロイ
GitHub Actionsの設定ができていれば、mainにPushするだけで自動でデプロイされます。
動確
Action Requiredは"Ask Later"を選択
参考
https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14
https://qiita.com/Futo_Horio/items/cd39976604692d6de001