はじめに
大尊敬するMatthew DevaneyさんがLinkedInで、Power AutomateでホストしたHTMLフォームを再現されていました。
ただただ圧巻です。Power AutomateクラウドフローでHTMLを返すと、フォームを起動させられるようです。
先日検証結果を動画にしました。
最近の実験結果🐟
— 出戻りガツオ🐟 Microsoft MVP (@DemodoriGatsuo) December 21, 2024
Power Automateを使ったHTMLフォーム & 疑似的なワンタイムパスワード認証。
詳細はQiitaに書く予定。#PowerAutomate #Qiita pic.twitter.com/rVyEaGbLhc
今回はその内容を踏まえて、HTMLフォーム
> パスワード認証
> ワンタイムパスワード認証
> 投稿
までの一連の流れをPower Automate
で再現したいと思います。
一連の流れ
今回必要になるのは、合計で5つのフローです。
いずれもHTTP 要求の受信時
にトリガーするフローとなります。
こちらのトリガーのレファレンスは、Power Automate
で検索するとヒットしにくいですが、Azure Logic Apps
で検索すると、下記の記事がヒットします。
このフローはプレミアムコネクタ
が使えるライセンスを必要とします。
必要となるフローは
-
HTML フォーム
を表示 - パスワード認証を実施
- ワンタイムパスワードを送信
- ワンタイムパスワードを検証
- 問い合わせの投稿
合計で5つになります。
1. HTML フォーム
を表示
まずはフローの全体像です。
並列となっているアクションは全て作成アクションです。
並列処理の後に実施する作成アクションでHTML
、CSS
、JavaScript
を全て定義し、応答として返すフローです。
安全性という観点で、このフローは実装に不向きです。
ほかのフローも同様ですが「フローをトリガーできるユーザー」は全てだれでもに設定します。
メソッドはGET
に設定してください。
HTTP URLはフロー保存後に作成されます。
■ 並列処理の作成アクション
※こちらは全てほかのフローをキックするURLです。
このような機密性の高い情報はセキュア入力
をオン
にすることを推奨します。
■ フォームやスクリプトを定義する作成アクション
下記を作成アクションに設定します。
長いので折りたたみ式にします
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login and Onetime password!!</title>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.card {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.form-title {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #2c3e50;
font-size: 14px;
}
.input-group {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 12px;
color: #7f8c8d;
font-size: 16px;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
textarea {
width: 100%;
padding: 12px 12px 12px 40px;
border: 2px solid #e0e0e0;
border-radius: 8px;
box-sizing: border-box;
font-size: 16px;
transition: all 0.3s ease;
}
input:focus,
textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
background-color: #3498db;
color: white;
padding: 14px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
width: 100%;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #2980b9;
}
button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.error {
color: #e74c3c;
font-size: 14px;
margin-top: 5px;
display: none;
}
.success {
color: #27ae60;
font-size: 14px;
margin-top: 5px;
display: none;
padding: 10px;
background-color: #d4edda;
border-radius: 4px;
}
.loading {
text-align: center;
margin: 10px 0;
display: none;
color: #7f8c8d;
}
#otpForm {
display: none;
}
.otp-input {
font-size: 24px;
letter-spacing: 0.5em;
text-align: center;
padding-left: 0.25em;
}
.timer {
text-align: center;
margin: 15px 0;
color: #7f8c8d;
font-size: 16px;
}
.info-message {
background-color: #ebf5fb;
border: 2px solid #3498db;
color: #2980b9;
padding: 15px;
margin: 15px 0;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.loading .spinner {
animation: spin 1s infinite linear;
display: inline-block;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="card">
<h1 class="form-title">Form</h1>
<!-- メインフォーム -->
<form id="loginContactForm">
<div class="form-group">
<label for="email">Mail</label>
<div class="input-group">
<i class="input-icon fas fa-envelope"></i>
<input type="email" id="email" name="email" required />
</div>
<div class="error" id="emailError"></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-group">
<i class="input-icon fas fa-lock"></i>
<input type="password" id="password" name="password" required />
</div>
<div class="error" id="passwordError"></div>
</div>
<div class="form-group">
<label for="subject">Subject</label>
<div class="input-group">
<i class="input-icon fas fa-heading"></i>
<input type="text" id="subject" name="subject" required />
</div>
<div class="error" id="subjectError"></div>
</div>
<div class="form-group">
<label for="message">Message</label>
<div class="input-group">
<i class="input-icon fas fa-comment" style="top: 12px"></i>
<textarea id="message" name="message" required></textarea>
</div>
<div class="error" id="messageError"></div>
</div>
<button type="submit" id="submitButton">
<i class="fas fa-paper-plane"></i> Submit
</button>
</form>
<!-- OTP確認フォーム -->
<form id="otpForm">
<div class="info-message">
<i class="fas fa-info-circle"></i>
確認コードをメールアドレスに送信しました。<br />
3分以内に入力してください。
</div>
<div class="timer" id="timer">
<i class="fas fa-clock"></i> <span></span>
</div>
<div class="form-group">
<label for="otp">ワンタイムパスワード</label>
<div class="input-group">
<i class="input-icon fas fa-key"></i>
<input
type="number"
id="otp"
name="otp"
class="otp-input"
minlength="6"
maxlength="6"
required
/>
</div>
<div class="error" id="otpError"></div>
</div>
<button type="submit"><i class="fas fa-check"></i> 確認</button>
</form>
<div class="loading" id="loading">
<i class="fas fa-circle-notch spinner"></i> 送信中...
</div>
<div class="success" id="successMessage">
<i class="fas fa-check-circle"></i> 送信が完了しました。
</div>
<div class="error" id="generalError"></div>
</div>
<script>
// フォームの参照を保持
const loginForm = document.getElementById("loginContactForm");
const otpForm = document.getElementById("otpForm");
const submitButton = document.getElementById("submitButton");
const loading = document.getElementById("loading");
const generalError = document.getElementById("generalError");
const successMessage = document.getElementById("successMessage");
// セッション情報を保持
let sessionData = {
email: "",
subject: "",
message: "",
sessionId: "",
};
// メインフォームの送信処理
loginForm.addEventListener("submit", async function (e) {
e.preventDefault();
resetErrors();
const formData = {
email: document.getElementById("email").value,
password: document.getElementById("password").value,
subject: document.getElementById("subject").value,
message: document.getElementById("message").value,
};
if (!validateForm(formData)) {
return;
}
submitButton.disabled = true;
loading.style.display = "block";
try {
// 1. パスワード認証
const authResponse = await fetch(
"@{outputs('ENDPOINT_PASSWORD')}",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: formData.email,
password: formData.password,
}),
}
);
if (!authResponse.ok) {
throw new Error("認証に失敗しました");
}
const authResult = await authResponse.json();
if (!authResult.authenticated) {
throw new Error(
"メールアドレスまたはパスワードが正しくありません。"
);
}
// セッションIDの生成
const currentSessionId = generateSessionId();
// 2. OTP生成・送信リクエスト
const otpResponse = await fetch(
"@{outputs('ENDPOINT_MAIN')}",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: formData.email,
sessionId: currentSessionId,
}),
}
);
if (!otpResponse.ok) {
throw new Error("確認コードの送信に失敗しました");
}
// セッションデータの保存
sessionData = {
email: formData.email,
subject: formData.subject,
message: formData.message,
sessionId: currentSessionId,
};
// メインフォームを非表示にし、OTP入力フォームを表示
loginForm.style.display = "none";
otpForm.style.display = "block";
startOtpTimer(180); // 3分のタイマーを開始
} catch (error) {
generalError.textContent = error.message;
generalError.style.display = "block";
} finally {
submitButton.disabled = false;
loading.style.display = "none";
}
});
// OTP検証フォームの送信処理
otpForm.addEventListener("submit", async function (e) {
e.preventDefault();
resetErrors();
const otpInput = document.getElementById("otp").value;
loading.style.display = "block";
try {
// OTP検証リクエスト
const verifyResponse = await fetch(
"@{outputs('ENDPOINT_VERIFY')}",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: sessionData.email,
otp: otpInput,
sessionId: sessionData.sessionId,
}),
}
);
if (!verifyResponse.ok) {
throw new Error("確認コードの検証に失敗しました");
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.verified) {
throw new Error("確認コードが正しくありません");
}
// 問い合わせデータの送信
const inquiryResponse = await fetch(
"@{outputs('ENDPOINT_RESPONSE')}",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: sessionData.email,
subject: sessionData.subject,
message: sessionData.message,
timestamp: new Date().toISOString(),
}),
}
);
if (!inquiryResponse.ok) {
throw new Error("問い合わせの送信に失敗しました");
}
// 成功メッセージの表示
successMessage.style.display = "block";
this.style.display = "none";
clearInterval(timerInterval);
} catch (error) {
generalError.textContent = error.message;
generalError.style.display = "block";
} finally {
loading.style.display = "none";
}
});
// OTPタイマー管理
let timerInterval = null;
function startOtpTimer(duration) {
const timerDisplay = document.getElementById("timer");
let timer = duration;
clearInterval(timerInterval);
timerInterval = setInterval(() => {
const minutes = Math.floor(timer / 60);
const seconds = timer % 60;
timerDisplay.textContent = `残り時間: ${minutes}:${seconds
.toString()
.padStart(2, "0")}`;
if (--timer < 0) {
clearInterval(timerInterval);
timerDisplay.textContent = "有効期限が切れました";
otpForm.style.display = "none";
loginForm.style.display = "block";
generalError.textContent =
"確認コードの有効期限が切れました。もう一度お試しください。";
generalError.style.display = "block";
}
}, 1000);
}
// セッションID生成
function generateSessionId() {
return (
"session-" +
Date.now() +
"-" +
Math.random().toString(36).substr(2, 9)
);
}
// バリデーション
function validateForm(formData) {
let isValid = true;
if (!formData.email || !isValidEmail(formData.email)) {
document.getElementById("emailError").textContent =
"有効なメールアドレスを入力してください。";
document.getElementById("emailError").style.display = "block";
isValid = false;
}
if (!formData.password || formData.password.length < 8) {
document.getElementById("passwordError").textContent =
"パスワードは8文字以上で入力してください。";
document.getElementById("passwordError").style.display = "block";
isValid = false;
}
if (!formData.subject || formData.subject.trim().length === 0) {
document.getElementById("subjectError").textContent =
"タイトルを入力してください。";
document.getElementById("subjectError").style.display = "block";
isValid = false;
}
if (!formData.message || formData.message.trim().length === 0) {
document.getElementById("messageError").textContent =
"問い合わせ内容を入力してください。";
document.getElementById("messageError").style.display = "block";
isValid = false;
}
return isValid;
}
// メールアドレスのバリデーション
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// エラーメッセージのリセット
function resetErrors() {
const errorElements = document.getElementsByClassName("error");
Array.from(errorElements).forEach((element) => {
element.style.display = "none";
element.textContent = "";
});
successMessage.style.display = "none";
generalError.style.display = "none";
}
</script>
</body>
</html>
Claudeに作ってもらいました。
まるっとスクリプトを含めて確認しています。
- メインフォームの送信処理
- ワンタイムパスワードの送信
- ワンタイムパスワードの検証
- 検証後の処理
上記のURLは前述の作成アクションから値を渡して設定します。
フォームで実行している処理のイメージ
それぞれ並んでいるハコHTML Form
、Password Auth
、OTP Service
、OTP Verify
、Response Service
の機能をPower Automateで担っています。
フローの最後の応答
は下記の値で返します。
Status Code
: 200
{
"Content-Type": "text/html"
}
Body
: @{outputs('BASIC_HTML_FORM')}
まったくエラー処理が加味されていないですね。
応答で返すContent-Type
だけポイントというところです。
2. パスワードの認証
実装方法は複数あると思います。
SQL Server
やDataverse
といった領域に保存し、認証を実施する方式です。
今回は複数人に公開する意図もないため、割愛します。
トリガーのスキーマ検証
をオンにして、フロー間の対話の形式だけ決めておきましょう。
Method
: POST
{
"type": "object",
"properties": {
"email": {
"type": "string"
},
"PASSWORD": {
"type": "string"
}
}
}
HTMLフォームの処理を継続させるためには、応答
で下記を返すことが必要です。
Status Code
: 200
{
"Content-Type": "application/json"
}
{
"authenticated": true
}
authenticated
がtrue
である場合に後続の処理を走らせます。
繰り返しになりますが実装は一切想定していないです。
3. ワンタイムパスワードの作成と送信
ワンタイムパスワードの作成と送信をPower Automate
で実行します。
■ トリガー
トリガーされる段階で、email
とsessionId
をフォームから受け取ります。
Method
: POST
{
"type": "object",
"properties": {
"email": {
"type": "string"
},
"sessionId": {
"type": "string"
}
}
}
Power Automate
の式でランダムな6桁数値を作ります。
formatNumber 関数とramdom関数を使います。
formatNumber(rand(0, 999999), 'd6')
■ ワンタイムパスワードの通知
SharePointのREST API
でメールを送信します。
このような形でSharePoint Online
サービスからnoReply
でメールが送信できるのですが、こちら残念ながら引退してしまいます。
悲しい。こちら個人的に好きなのですが、現状の設定値を記載します。
- サイトのアドレス:
@{parameters('SP_SITE (gtai_SP_SITE)')}
- Method:
POST
- URI:
_api/SP.Utilities.Utility.SendEmail
{
"Accept": "application/json;odata=verbose",
"Content-Type": "application/json;odata=verbose"
}
{
"properties": {
"To": {
"results": ["@{triggerBody()?['email']}"]
},
"Subject": "ワンタイムパスワード",
"Body": "@{outputs('OTP')}"
}
}
引退しないでほしい。と思っていますが諸行無常、致し方なしです。
■ ワンタイムパスワードの保存
項目の作成
ワンタイムパスワードはSharePoint Lists
に一時的に保存します。
sessionId
と作成したワンタイムパスワード
を後ほど検証で使います。
そして応答
です。
ここは後続のステップに、このフローでは関係しないため割愛します。
4. ワンタイムパスワードの検証
まずはトリガーです。
{
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"otp": {
"type": "string"
},
"formData": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"subject": {
"type": "string"
},
"message": {
"type": "string"
},
"sessionId": {
"type": "string"
}
}
}
}
}
複数の項目の取得を使って、sessionId
の有無を調べます。
sessionId eq '@{triggerBody()?['sessionId']}' and otp eq '@{triggerBody()?['otp']}'
sessionId
とotp
が両方マッチするアイテム数を返します。
そちらをカウントする関数が下記です。
length(outputs('複数の項目の取得')?['body/value'])
配列の要素数が0
以上であれば、対象のレコードありのため、認証を進めましょう。
応答の本文であるverified
がtrue
である場合に、後続の処理が走ります。
5. 認証後の処理
HTMLフォームに入力された値をアダプティブカード
として投稿します。
{
"type": "AdaptiveCard",
"version": "1.4",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"body": [
{
"type": "Container",
"style": "emphasis",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "新規問い合わせ",
"weight": "Bolder",
"size": "Large",
"color": "Accent"
}
]
}
]
}
]
},
{
"type": "Container",
"items": [
{
"type": "FactSet",
"facts": [
{
"title": "送信日時",
"value": "@{convertTimeZone(triggerBody()?['timestamp'],'UTC','Tokyo Standard Time')}"
}
]
}
]
},
{
"type": "Container",
"style": "default",
"items": [
{
"type": "TextBlock",
"text": "件名",
"weight": "Bolder",
"size": "Medium",
"color": "Accent"
},
{
"type": "TextBlock",
"text": "@{triggerBody()?['subject']}",
"wrap": true,
"spacing": "Small"
},
{
"type": "TextBlock",
"text": "問い合わせ内容",
"weight": "Bolder",
"size": "Medium",
"color": "Accent",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "@{triggerBody()?['message']}",
"wrap": true,
"spacing": "Small"
}
]
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "返信する",
"url": "mailto:${email}?subject=Re: ${subject}",
"style": "positive"
},
{
"type": "Action.ShowCard",
"title": "メモを残す",
"card": {
"type": "AdaptiveCard",
"body": [
{
"type": "Input.Text",
"placeholder": "メモを入力してください",
"id": "comment",
"isMultiline": true
}
],
"actions": [
{
"type": "Action.Submit",
"title": "保存",
"style": "positive"
}
]
}
}
]
}
マスクしていますが、フォームに入力された値は、メールアドレス含め全て使えます。
最終的にアダプティブカードとして投稿します。
アダプティブカードの応答によって、後続の処理を実施することも可能です。
おわりに
動画を見て「うわーすごい!!」と感動して、がーっとやってしまいました!
使う場面はなさそうですが、こういうことがいつか生きると信じて・・・、Power Lifeを楽しみましょう!