0
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?

Kiroと要件を整理しながら、認証付きアニバーサリーサイトをAWSで構築してみた

0
Posted at

目次

#1.はじめに
#2.環境
#3.要件定義
#4.システム構成
#5.環境構築と実際の画面
#6.おわりに

1.はじめに

何ヶ月か前の話かつ私事となりますが、先日子供が生まれました!
妻の妊娠中に何かお祝いのためにできることがないか考え、Kiroを活用してアニバーサリーサイトを作成してみました。
本記事はアニバーサリーサイト作成までの要件整理や構成内容についてご紹介します。

2.環境

OS:macOS
IDE:Kiro(モデルAuto)

KiroではClaudeなどモデルを個別に指定することができますが、Autoモードにしてモデルの選択をお任せするとクレジット倍率を1.0xにして消費クレジットを節約することが可能です。
今回は無料枠で開発を行うためにAutoで利用しています

参考ですが以前Windowsの環境でインストールからタスク管理サイトの作成までを実施した記事は以下となります。
https://qiita.com/ss_IT_study/items/2bc4a34c0f2df79dde7b

3.要件定義

Kiroは今までのバイブコーディングと違いスペック駆動開発が可能という特徴があります。
今回要件はKiroと壁打ちしながら整理をしたかったので、スペックモードでの開発を行いました。

Kiroのスペック駆動開発では、ユーザーとKiroで会話を行いながら、requirements.mdというファイルに要件を書き出していきます。その後、design.mdに設計を書き出し、最後にtasks.mdに実装、テストなどのタスクを書き出して、開発を進めていきます。

今回の要件定義ではまず実現したい機能要件を伝えたところ、s3の署名つきURLでの実装を提案されました。個人サイトなのでケースによってはこれでもOKなのですが、私は念の為セキュリティも強化したいという要件を伝え、認証機能や境界防御も含むように要件定義を進めていきました。
具体的には以下のような変更が発生しています。

  • 署名付きURL
    →URLを知っている人ならアクセスできる
    →アカウント管理をして認証機能を追加したい
    →Cognitoを使って認証付きに変更

  • 境界防御なし
    →インターネットに公開するため最低限のWAFを採用

4.システム構成

要件定義の結果、以下の構成でサイトを作成しました。
このmermaid形式の構成図や認証フローも要件定義の内容を元にKiroが作成してくれています。


システム構成図


認証フロー詳細


利用サービス一覧

サービス リージョン 用途
Amazon S3 ap-northeast-1(東京) 静的ファイル(HTML / CSS / JS / 画像)のホスティング。OACで直接アクセスを遮断
Amazon CloudFront Global CDN。S3へのアクセスを OAC 経由に集約し、Lambda@Edge を Viewer Request でフック
AWS Lambda@Edge us-east-1(バージニア北部) CloudFront の Viewer Request を横断するエッジ関数。JWT 検証・OAuth2 コールバック処理・ログアウト処理を担当
Amazon Cognito us-east-1(バージニア北部) ユーザープール管理・OAuth2 / OIDC 認証基盤。家族アカウントの管理と JWT 発行を担当
AWS WAF Global(CloudFront に紐付け) レートベースルール(5分間100リクエスト上限)でブルートフォース・DDoS 攻撃を緩和
AWS Budgets Global 月次コスト予算($10)を設定し、超過時にメールアラートを送信

セキュリティのポイント

  • S3 は完全非公開 — パブリックアクセスをすべてブロック。CloudFront の OAC(Origin Access Control)経由のみアクセス可能
  • JWT はサーバー側(Lambda@Edge)で検証 — ブラウザの JavaScript には秘密情報を持たせない
  • HttpOnly Cookieid_tokenHttpOnly; Secure; SameSite=Strict で保存し、XSS によるトークン窃取を防止
  • OAuth2 State パラメータ — CSRF 攻撃対策として State を生成・検証
  • WAF レートリミット — 同一 IP からの短時間多量リクエストをブロック

Lambda@Edgeの実装

実装コード
import { createPublicKey, createVerify } from 'crypto';
import https from 'https';

// ===== 設定値(書き換えてください) =====
const COGNITO_REGION = '';
const USER_POOL_ID = '';
const CLIENT_ID = '';
const COGNITO_DOMAIN = '';
const CLOUDFRONT_DOMAIN = '';
// =========================================

const JWKS_URL = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json`;

let cachedJwks = null;

function httpsGet(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let data = '';
      res.on('data', chunk => { data += chunk; });
      res.on('end', () => resolve(JSON.parse(data)));
    }).on('error', reject);
  });
}

function httpsPost(url, body) {
  return new Promise((resolve, reject) => {
    const urlObj = new URL(url);
    const options = {
      hostname: urlObj.hostname,
      path: urlObj.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(body),
      },
    };
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => { data += chunk; });
      res.on('end', () => resolve(JSON.parse(data)));
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

async function getJwks() {
  if (cachedJwks) return cachedJwks;
  cachedJwks = await httpsGet(JWKS_URL);
  return cachedJwks;
}

function base64urlDecode(str) {
  return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}

async function verifyToken(token) {
  try {
    const [headerB64, payloadB64, signatureB64] = token.split('.');
    const header = JSON.parse(base64urlDecode(headerB64).toString());
    const payload = JSON.parse(base64urlDecode(payloadB64).toString());

    if (payload.exp < Math.floor(Date.now() / 1000)) return false;
    const expectedIss = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${USER_POOL_ID}`;
    if (payload.iss !== expectedIss) return false;
    if (payload.token_use !== 'id') return false;
    if (payload.aud !== CLIENT_ID && payload.client_id !== CLIENT_ID) return false;

    const jwks = await getJwks();
    const jwk = jwks.keys.find(k => k.kid === header.kid);
    if (!jwk) return false;

    const publicKey = createPublicKey({ key: jwk, format: 'jwk' });
    const verifier = createVerify('RSA-SHA256');
    verifier.update(`${headerB64}.${payloadB64}`);
    return verifier.verify(publicKey, base64urlDecode(signatureB64));
  } catch {
    return false;
  }
}

function parseCookies(cookieHeader) {
  const cookies = {};
  if (!cookieHeader || !cookieHeader[0]) return cookies;
  cookieHeader[0].value.split(';').forEach(c => {
    const [k, ...v] = c.trim().split('=');
    cookies[k.trim()] = v.join('=');
  });
  return cookies;
}

function buildCognitoLoginUrl(redirectUri, state) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: redirectUri,
    scope: 'openid email',
    state,
  });
  return `${COGNITO_DOMAIN}/oauth2/authorize?${params}`;
}

function generateState() {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  return Array.from({ length: 32 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  const cookies = parseCookies(request.headers['cookie']);
  const idToken = cookies['id_token'];
  const redirectUri = `${CLOUDFRONT_DOMAIN}/callback`;

  // /logout: Cookie削除 → Cognitoログアウトへリダイレクト
  if (request.uri.startsWith('/logout')) {
    const logoutUrl = `${COGNITO_DOMAIN}/logout?client_id=${CLIENT_ID}&logout_uri=${encodeURIComponent(CLOUDFRONT_DOMAIN + '/')}`;
    return {
      status: '302',
      headers: {
        location: [{ key: 'Location', value: logoutUrl }],
        'set-cookie': [
          { key: 'Set-Cookie', value: `id_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
          { key: 'Set-Cookie', value: `oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
        ],
        'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
      },
    };
  }

  // /callback: state検証 → トークン交換 → Cookie保存
  if (request.uri.startsWith('/callback')) {
    const qs = new URLSearchParams(request.querystring || '');
    const code = qs.get('code');
    const returnedState = qs.get('state');
    const savedState = cookies['oauth_state'];

    if (!returnedState || returnedState !== savedState || !code) {
      return {
        status: '302',
        headers: {
          location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
          'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
        },
      };
    }

    try {
      const body = new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: CLIENT_ID,
        redirect_uri: redirectUri,
        code,
      }).toString();

      const tokens = await httpsPost(`${COGNITO_DOMAIN}/oauth2/token`, body);

      if (!tokens.id_token) {
        return {
          status: '302',
          headers: {
            location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
            'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
          },
        };
      }

      return {
        status: '302',
        headers: {
          location: [{ key: 'Location', value: CLOUDFRONT_DOMAIN + '/' }],
          'set-cookie': [
            { key: 'Set-Cookie', value: `id_token=${tokens.id_token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600` },
            { key: 'Set-Cookie', value: `oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
          ],
          'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
        },
      };
    } catch (e) {
      return {
        status: '302',
        headers: {
          location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
          'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
        },
      };
    }
  }

  // その他: JWT検証
  if (idToken && await verifyToken(idToken)) {
    return request;
  }

  // 未認証 → Cognitoログイン画面へ
  const state = generateState();
  return {
    status: '302',
    headers: {
      location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, state) }],
      'set-cookie': [{ key: 'Set-Cookie', value: `oauth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=300` }],
      'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
    },
  };
};

5.環境構築と実際の画面

KiroではAWSリソースに対する変更なども可能なのですが、今回はクレジットの節約とハンズオン観点で環境構築はマネジメントコンソールを用いて手動で行いました。
ただし、今回の要件に応じたリソース作成の手順やトラブルシューティングはKiroに助けてもらっています。
実際に手を動かすと気づきもあり、全自動と手動どちらもいいところがあるなというのが私の感想です。(Lambda@Edgeの理解がかなり薄かったのですが、役割や東京リージョンで使えない制約など学びもありました)

実際の画面はこんな感じです。マスクをかけているので簡易な内容ではありますが、簡単なクイズやギャラリーも含めて家族には喜んでもらえました。(この辺のマスク作業も全部Kiroがやってくれています)

image.png
image.png

6.おわりに

Kiroを用いてアニバーサリーサイトの要件定義、実装、公開を行なったプロセスを紹介しました。
私は基盤寄りのエンジニアなので、アプリケーションの作成についてあまり知見はないのですが、AWSやKiroを利用することで個人のサイトが柔軟かつ簡単に公開できました。
知識で知っていても実際にやってみるといろいろな気づきがあるので、今後もKiroを活用してみたいなと思います。
(先日のAWS SummitでSWAGは残念ながら手に入れられませんでしたが、おばけのキャラクターもかわいくてお気に入りです)

0
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
0
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?