iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Build on AWS

30 天將工作室 SaaS 產品部署起來系列 第 25

Day 25: 30天部署SaaS產品到AWS-AWS Cognito 整合與用戶池管理

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 24 的 SES 郵件服務建置,我們已經有了完整的用戶溝通管道。今天我們要整合 AWS Cognito,這是 AWS 提供的託管式用戶認證服務。雖然我們在 Day 25 後端篇已經實作了自建的 JWT 認證,但 Cognito 提供了許多額外的企業級功能,包括 MFA、社交登入、用戶池管理等。我們將探討何時該用 Cognito,以及如何整合。

Cognito vs 自建認證比較

/**
 * AWS Cognito vs 自建認證系統
 *
 * ┌──────────────────┬─────────────────┬──────────────────┐
 * │ 特性             │ AWS Cognito     │ 自建 (JWT+Redis) │
 * ├──────────────────┼─────────────────┼──────────────────┤
 * │ 開發時間         │ 🟢 快速 (數小時)│ 🔴 慢 (數天)     │
 * │ 維護成本         │ 🟢 低 (AWS管理) │ 🔴 高 (自行維護) │
 * │ 安全性           │ 🟢 企業級       │ 🟡 取決於實作    │
 * │ 功能完整性       │ 🟢 豐富         │ 🟡 需自行開發    │
 * │ 客製化彈性       │ 🔴 受限         │ 🟢 完全控制      │
 * │ 成本 (小規模)    │ 🟢 低/免費      │ 🟡 中等          │
 * │ 成本 (大規模)    │ 🔴 高           │ 🟢 低            │
 * │ 供應商鎖定       │ 🔴 是           │ 🟢 否            │
 * │ 用戶體驗控制     │ 🟡 有限         │ 🟢 完全控制      │
 * │ MFA 支援         │ 🟢 內建         │ 🟡 需自行開發    │
 * │ 社交登入         │ 🟢 內建         │ 🟡 需自行整合    │
 * └──────────────────┴─────────────────┴──────────────────┘
 *
 * 建議使用場景:
 *
 * ✅ 使用 Cognito:
 * - 快速 MVP
 * - 企業合規需求(SOC2, HIPAA)
 * - 需要 MFA、社交登入等完整功能
 * - 團隊資源有限
 * - 用戶規模 < 50萬 MAU
 *
 * ✅ 自建認證:
 * - 需要完全客製化
 * - 特殊業務邏輯
 * - 用戶規模大(> 100萬 MAU)
 * - 成本敏感
 * - 避免供應商鎖定
 *
 * 💡 我們的策略:混合式
 * - B2C 用戶: Cognito (快速、功能完整)
 * - B2B 企業: 自建 (客製化、SSO 整合)
 */

Cognito 核心概念

/**
 * AWS Cognito 架構
 *
 * ┌─────────────────────────────────────────────┐
 * │           AWS Cognito 服務架構              │
 * └─────────────────────────────────────────────┘
 *
 * 1. User Pool (用戶池)
 *    - 用戶目錄服務
 *    - 處理註冊、登入、密碼重設
 *    - 內建 JWT Token 發放
 *    - 支援 MFA、自訂屬性
 *
 * 2. Identity Pool (身份池)
 *    - 聯合身份管理
 *    - 授予 AWS 資源存取權限
 *    - 支援匿名存取
 *    - 整合多個 IdP (Identity Provider)
 *
 * 3. App Client
 *    - 應用程式介面
 *    - 定義 OAuth 流程
 *    - 設定 Token 有效期
 *
 * 4. Hosted UI
 *    - AWS 託管的登入頁面
 *    - 可自訂樣式
 *    - 支援社交登入
 *
 * 定價 (ap-northeast-1):
 * - 前 50,000 MAU: 免費
 * - 50,001 - 100,000: $0.00550 per MAU
 * - 100,001 - 1,000,000: $0.00460 per MAU
 * - MFA SMS: $0.05 per message
 * - 進階安全功能: $0.05 per MAU
 */

CDK 建立 Cognito User Pool

// infrastructure/lib/cognito-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sns from 'aws-cdk-lib/aws-sns';
import { Construct } from 'constructs';

export class CognitoStack extends cdk.Stack {
  public readonly userPool: cognito.UserPool;
  public readonly userPoolClient: cognito.UserPoolClient;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 建立 User Pool
    this.userPool = new cognito.UserPool(this, 'KyoUserPool', {
      userPoolName: 'kyo-users',

      // 自助註冊
      selfSignUpEnabled: true,

      // 登入方式
      signInAliases: {
        email: true,
        username: false,
        phone: false,
      },

      // 自動驗證
      autoVerify: {
        email: true,
      },

      // 標準屬性
      standardAttributes: {
        email: {
          required: true,
          mutable: false, // 不可變更
        },
        fullname: {
          required: true,
          mutable: true,
        },
        phoneNumber: {
          required: false,
          mutable: true,
        },
      },

      // 自訂屬性
      customAttributes: {
        tenantId: new cognito.StringAttribute({
          minLen: 1,
          maxLen: 64,
          mutable: false,
        }),
        role: new cognito.StringAttribute({
          minLen: 1,
          maxLen: 20,
          mutable: true,
        }),
        plan: new cognito.StringAttribute({
          minLen: 1,
          maxLen: 20,
          mutable: true,
        }),
      },

      // 密碼政策
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
        requireSymbols: true,
        tempPasswordValidity: cdk.Duration.days(3),
      },

      // 帳號復原
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,

      // MFA 設定
      mfa: cognito.Mfa.OPTIONAL,
      mfaSecondFactor: {
        sms: true,
        otp: true, // TOTP
      },

      // Email 設定
      email: cognito.UserPoolEmail.withSES({
        fromEmail: 'noreply@kyong.com',
        fromName: 'Kyo Team',
        sesRegion: 'ap-northeast-1',
        sesVerifiedDomain: 'kyong.com',
      }),

      // SMS 設定 (用於 MFA)
      smsRole: this.createSMSRole(),

      // 進階安全功能
      advancedSecurityMode: cognito.AdvancedSecurityMode.ENFORCED,

      // Lambda Triggers
      lambdaTriggers: {
        preSignUp: this.createPreSignUpTrigger(),
        postConfirmation: this.createPostConfirmationTrigger(),
        preAuthentication: this.createPreAuthenticationTrigger(),
      },

      // 裝置追蹤
      deviceTracking: {
        challengeRequiredOnNewDevice: true,
        deviceOnlyRememberedOnUserPrompt: true,
      },

      // 用戶刪除保護
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    // 建立 App Client
    this.userPoolClient = this.userPool.addClient('WebAppClient', {
      userPoolClientName: 'web-app',

      // OAuth 流程
      oAuth: {
        flows: {
          authorizationCodeGrant: true,
          implicitCodeGrant: false,
        },
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],
        callbackUrls: [
          'http://localhost:5173/auth/callback',
          'https://app.kyong.com/auth/callback',
        ],
        logoutUrls: [
          'http://localhost:5173',
          'https://app.kyong.com',
        ],
      },

      // Token 有效期
      accessTokenValidity: cdk.Duration.minutes(60),
      idTokenValidity: cdk.Duration.minutes(60),
      refreshTokenValidity: cdk.Duration.days(30),

      // 啟用 Token 撤銷
      enableTokenRevocation: true,

      // 防止用戶存在枚舉
      preventUserExistenceErrors: true,

      // 支援的身份提供商
      supportedIdentityProviders: [
        cognito.UserPoolClientIdentityProvider.COGNITO,
        cognito.UserPoolClientIdentityProvider.GOOGLE,
        cognito.UserPoolClientIdentityProvider.FACEBOOK,
      ],

      // 讀寫屬性
      readAttributes: new cognito.ClientAttributes()
        .withStandardAttributes({
          email: true,
          emailVerified: true,
          fullname: true,
          phoneNumber: true,
        })
        .withCustomAttributes('tenantId', 'role', 'plan'),

      writeAttributes: new cognito.ClientAttributes()
        .withStandardAttributes({
          fullname: true,
          phoneNumber: true,
        }),
    });

    // 建立 User Pool Domain (for Hosted UI)
    const domain = this.userPool.addDomain('CognitoDomain', {
      cognitoDomain: {
        domainPrefix: 'kyo-auth',
      },
    });

    // 設定社交登入:Google
    const googleProvider = new cognito.UserPoolIdentityProviderGoogle(this, 'Google', {
      userPool: this.userPool,
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      scopes: ['profile', 'email', 'openid'],
      attributeMapping: {
        email: cognito.ProviderAttribute.GOOGLE_EMAIL,
        fullname: cognito.ProviderAttribute.GOOGLE_NAME,
        profilePicture: cognito.ProviderAttribute.GOOGLE_PICTURE,
      },
    });

    // App Client 需依賴 IdP
    this.userPoolClient.node.addDependency(googleProvider);

    // 輸出
    new cdk.CfnOutput(this, 'UserPoolId', {
      value: this.userPool.userPoolId,
      description: 'Cognito User Pool ID',
    });

    new cdk.CfnOutput(this, 'UserPoolClientId', {
      value: this.userPoolClient.userPoolClientId,
      description: 'Cognito User Pool Client ID',
    });

    new cdk.CfnOutput(this, 'UserPoolDomain', {
      value: `https://${domain.domainName}.auth.ap-northeast-1.amazoncognito.com`,
      description: 'Cognito Hosted UI Domain',
    });
  }

  /**
   * 建立 SMS 角色(用於 MFA)
   */
  private createSMSRole(): iam.Role {
    return new iam.Role(this, 'CognitoSMSRole', {
      assumedBy: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
      inlinePolicies: {
        sns: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['sns:Publish'],
              resources: ['*'],
            }),
          ],
        }),
      },
    });
  }

  /**
   * Pre Sign Up Trigger
   * 在用戶註冊前執行,可用於驗證、自動確認等
   */
  private createPreSignUpTrigger(): lambda.Function {
    return new lambda.Function(this, 'PreSignUpTrigger', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async (event) => {
          console.log('Pre Sign Up Event:', JSON.stringify(event, null, 2));

          // 自動確認用戶(跳過 email 驗證)
          // 實際應該發送驗證郵件
          if (event.request.userAttributes.email) {
            event.response.autoConfirmUser = true;
            event.response.autoVerifyEmail = true;
          }

          // 設定自訂屬性
          event.response.claimsOverrideDetails = {
            claimsToAddOrOverride: {
              'custom:tenantId': 'default-tenant',
              'custom:role': 'user',
              'custom:plan': 'free',
            },
          };

          return event;
        };
      `),
      timeout: cdk.Duration.seconds(10),
    });
  }

  /**
   * Post Confirmation Trigger
   * 用戶確認後執行,可用於發送歡迎郵件、建立租戶等
   */
  private createPostConfirmationTrigger(): lambda.Function {
    return new lambda.Function(this, 'PostConfirmationTrigger', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async (event) => {
          console.log('Post Confirmation Event:', JSON.stringify(event, null, 2));

          const { email, name } = event.request.userAttributes;

          // TODO: 呼叫後端 API 建立租戶
          // TODO: 發送歡迎郵件

          return event;
        };
      `),
      timeout: cdk.Duration.seconds(10),
    });
  }

  /**
   * Pre Authentication Trigger
   * 登入前執行,可用於 IP 檢查、異常登入偵測等
   */
  private createPreAuthenticationTrigger(): lambda.Function {
    return new lambda.Function(this, 'PreAuthenticationTrigger', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async (event) => {
          console.log('Pre Authentication Event:', JSON.stringify(event, null, 2));

          // 檢查 IP 白名單(範例)
          // const ipAddress = event.request.userContextData?.sourceIp;

          // 檢查登入嘗試次數
          // const failedAttempts = await checkFailedAttempts(event.userName);
          // if (failedAttempts > 5) {
          //   throw new Error('Account locked due to multiple failed attempts');
          // }

          return event;
        };
      `),
      timeout: cdk.Duration.seconds(10),
    });
  }
}

後端整合 Cognito

// apps/kyo-otp-service/src/lib/cognito-client.ts
import {
  CognitoIdentityProviderClient,
  SignUpCommand,
  InitiateAuthCommand,
  RespondToAuthChallengeCommand,
  GetUserCommand,
  UpdateUserAttributesCommand,
  ChangePasswordCommand,
  ForgotPasswordCommand,
  ConfirmForgotPasswordCommand,
  AdminGetUserCommand,
  AdminUpdateUserAttributesCommand,
} from '@aws-sdk/client-cognito-identity-provider';

export class CognitoService {
  private client: CognitoIdentityProviderClient;
  private userPoolId: string;
  private clientId: string;

  constructor(config: {
    region: string;
    userPoolId: string;
    clientId: string;
  }) {
    this.client = new CognitoIdentityProviderClient({
      region: config.region,
    });
    this.userPoolId = config.userPoolId;
    this.clientId = config.clientId;
  }

  /**
   * 註冊用戶
   */
  async signUp(data: {
    email: string;
    password: string;
    name: string;
  }): Promise<{
    userSub: string;
    confirmed: boolean;
  }> {
    const command = new SignUpCommand({
      ClientId: this.clientId,
      Username: data.email,
      Password: data.password,
      UserAttributes: [
        { Name: 'email', Value: data.email },
        { Name: 'name', Value: data.name },
      ],
    });

    const response = await this.client.send(command);

    return {
      userSub: response.UserSub!,
      confirmed: response.UserConfirmed || false,
    };
  }

  /**
   * 登入 (USER_PASSWORD_AUTH flow)
   */
  async login(data: {
    email: string;
    password: string;
  }): Promise<{
    accessToken: string;
    idToken: string;
    refreshToken: string;
    expiresIn: number;
  }> {
    const command = new InitiateAuthCommand({
      ClientId: this.clientId,
      AuthFlow: 'USER_PASSWORD_AUTH',
      AuthParameters: {
        USERNAME: data.email,
        PASSWORD: data.password,
      },
    });

    const response = await this.client.send(command);

    if (response.ChallengeName) {
      // 需要處理挑戰(如 MFA)
      throw new Error(`Challenge required: ${response.ChallengeName}`);
    }

    if (!response.AuthenticationResult) {
      throw new Error('Authentication failed');
    }

    return {
      accessToken: response.AuthenticationResult.AccessToken!,
      idToken: response.AuthenticationResult.IdToken!,
      refreshToken: response.AuthenticationResult.RefreshToken!,
      expiresIn: response.AuthenticationResult.ExpiresIn!,
    };
  }

  /**
   * 刷新 Token
   */
  async refreshToken(refreshToken: string): Promise<{
    accessToken: string;
    idToken: string;
    expiresIn: number;
  }> {
    const command = new InitiateAuthCommand({
      ClientId: this.clientId,
      AuthFlow: 'REFRESH_TOKEN_AUTH',
      AuthParameters: {
        REFRESH_TOKEN: refreshToken,
      },
    });

    const response = await this.client.send(command);

    if (!response.AuthenticationResult) {
      throw new Error('Token refresh failed');
    }

    return {
      accessToken: response.AuthenticationResult.AccessToken!,
      idToken: response.AuthenticationResult.IdToken!,
      expiresIn: response.AuthenticationResult.ExpiresIn!,
    };
  }

  /**
   * 取得用戶資訊
   */
  async getUser(accessToken: string): Promise<{
    username: string;
    attributes: Record<string, string>;
  }> {
    const command = new GetUserCommand({
      AccessToken: accessToken,
    });

    const response = await this.client.send(command);

    const attributes: Record<string, string> = {};
    response.UserAttributes?.forEach((attr) => {
      attributes[attr.Name] = attr.Value!;
    });

    return {
      username: response.Username!,
      attributes,
    };
  }

  /**
   * 更新用戶屬性
   */
  async updateUserAttributes(
    accessToken: string,
    attributes: Record<string, string>
  ): Promise<void> {
    const command = new UpdateUserAttributesCommand({
      AccessToken: accessToken,
      UserAttributes: Object.entries(attributes).map(([name, value]) => ({
        Name: name,
        Value: value,
      })),
    });

    await this.client.send(command);
  }

  /**
   * 變更密碼
   */
  async changePassword(data: {
    accessToken: string;
    previousPassword: string;
    proposedPassword: string;
  }): Promise<void> {
    const command = new ChangePasswordCommand({
      AccessToken: data.accessToken,
      PreviousPassword: data.previousPassword,
      ProposedPassword: data.proposedPassword,
    });

    await this.client.send(command);
  }

  /**
   * 忘記密碼(發送驗證碼)
   */
  async forgotPassword(email: string): Promise<void> {
    const command = new ForgotPasswordCommand({
      ClientId: this.clientId,
      Username: email,
    });

    await this.client.send(command);
  }

  /**
   * 確認忘記密碼(使用驗證碼重設)
   */
  async confirmForgotPassword(data: {
    email: string;
    code: string;
    newPassword: string;
  }): Promise<void> {
    const command = new ConfirmForgotPasswordCommand({
      ClientId: this.clientId,
      Username: data.email,
      ConfirmationCode: data.code,
      Password: data.newPassword,
    });

    await this.client.send(command);
  }

  /**
   * 管理員取得用戶資訊
   */
  async adminGetUser(username: string): Promise<{
    username: string;
    status: string;
    attributes: Record<string, string>;
  }> {
    const command = new AdminGetUserCommand({
      UserPoolId: this.userPoolId,
      Username: username,
    });

    const response = await this.client.send(command);

    const attributes: Record<string, string> = {};
    response.UserAttributes?.forEach((attr) => {
      attributes[attr.Name] = attr.Value!;
    });

    return {
      username: response.Username!,
      status: response.UserStatus!,
      attributes,
    };
  }

  /**
   * 管理員更新用戶屬性
   */
  async adminUpdateUserAttributes(
    username: string,
    attributes: Record<string, string>
  ): Promise<void> {
    const command = new AdminUpdateUserAttributesCommand({
      UserPoolId: this.userPoolId,
      Username: username,
      UserAttributes: Object.entries(attributes).map(([name, value]) => ({
        Name: name,
        Value: value,
      })),
    });

    await this.client.send(command);
  }
}

JWT Token 驗證

// apps/kyo-otp-service/src/plugins/cognito-auth.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import { CognitoJwtVerifier } from 'aws-jwt-verify';

declare module 'fastify' {
  interface FastifyRequest {
    cognitoUser?: {
      sub: string;
      email: string;
      name: string;
      tenantId: string;
      role: string;
    };
  }
}

const cognitoAuthPlugin: FastifyPluginAsync = async (server) => {
  // 建立 JWT Verifier
  const accessTokenVerifier = CognitoJwtVerifier.create({
    userPoolId: process.env.COGNITO_USER_POOL_ID!,
    tokenUse: 'access',
    clientId: process.env.COGNITO_CLIENT_ID!,
  });

  const idTokenVerifier = CognitoJwtVerifier.create({
    userPoolId: process.env.COGNITO_USER_POOL_ID!,
    tokenUse: 'id',
    clientId: process.env.COGNITO_CLIENT_ID!,
  });

  /**
   * onRequest Hook: 自動驗證 Cognito Token
   */
  server.addHook('onRequest', async (request) => {
    const authHeader = request.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return; // 不強制,讓路由自行決定
    }

    const token = authHeader.substring(7);

    try {
      // 先嘗試驗證 Access Token
      let payload = await accessTokenVerifier.verify(token);

      // 如果失敗,嘗試 ID Token
      if (!payload) {
        payload = await idTokenVerifier.verify(token);
      }

      // 解析用戶資訊
      request.cognitoUser = {
        sub: payload.sub,
        email: payload.email || '',
        name: payload.name || '',
        tenantId: payload['custom:tenantId'] || '',
        role: payload['custom:role'] || 'user',
      };
    } catch (err) {
      // Token 無效,但不拋出錯誤
      server.log.debug('Invalid Cognito token:', err);
    }
  });

  /**
   * 裝飾器:要求 Cognito 認證
   */
  server.decorate('requireCognitoAuth', () => {
    return async (request: any, reply: any) => {
      if (!request.cognitoUser) {
        throw server.httpErrors.unauthorized('Cognito authentication required');
      }
    };
  });
};

export default fp(cognitoAuthPlugin, {
  name: 'cognito-auth',
});

混合式認證策略

// apps/kyo-otp-service/src/routes/hybrid-auth.ts
import { FastifyPluginAsync } from 'fastify';

/**
 * 混合式認證路由
 *
 * 支援:
 * 1. 自建 JWT (適合 B2B 企業客戶)
 * 2. Cognito (適合 B2C 一般用戶)
 */
export const hybridAuthRoutes: FastifyPluginAsync = async (server) => {
  /**
   * 統一的認證檢查
   */
  const requireAuth = async (request: any) => {
    // 優先檢查 Cognito
    if (request.cognitoUser) {
      return {
        userId: request.cognitoUser.sub,
        email: request.cognitoUser.email,
        provider: 'cognito',
      };
    }

    // 檢查自建 JWT
    if (request.user) {
      return {
        userId: request.user.userId,
        email: request.user.email,
        provider: 'jwt',
      };
    }

    throw server.httpErrors.unauthorized('Authentication required');
  };

  /**
   * 範例:取得用戶資訊(支援兩種認證)
   */
  server.get('/profile', async (request, reply) => {
    const auth = await requireAuth(request);

    let user;

    if (auth.provider === 'cognito') {
      // 從 Cognito 取得用戶資訊
      user = await server.cognitoService.getUser(
        request.headers.authorization!.substring(7)
      );
    } else {
      // 從自建資料庫取得用戶資訊
      user = await server.db.user.findUnique({
        where: { id: auth.userId },
      });
    }

    return reply.send({
      success: true,
      user,
      provider: auth.provider,
    });
  });
};

成本分析與優化

/**
 * AWS Cognito 成本分析 (ap-northeast-1)
 *
 * 基礎定價:
 * - 前 50,000 MAU: 免費
 * - 50,001 - 100,000: $0.0055 per MAU
 * - 100,001 - 1,000,000: $0.0046 per MAU
 * - 1,000,000+: $0.0025 per MAU
 *
 * 額外費用:
 * - SMS MFA: $0.05 per message
 * - 進階安全功能: $0.05 per MAU
 * - SAML/OIDC IdP: 無額外費用
 *
 * MAU (Monthly Active User) 定義:
 * - 在該月執行過身份操作的唯一用戶
 * - 操作包括:登入、註冊、Token 刷新、密碼重設
 */

interface CognitoCostEstimate {
  monthlyActiveUsers: number;
  smsMessagesPerMonth?: number;
  advancedSecurityEnabled?: boolean;
}

function calculateCognitoCost(config: CognitoCostEstimate): {
  baseCost: number;
  smsCost: number;
  advancedSecurityCost: number;
  totalMonthlyCost: number;
} {
  const { monthlyActiveUsers, smsMessagesPerMonth = 0, advancedSecurityEnabled = false } = config;

  let baseCost = 0;

  // 計算基礎費用
  if (monthlyActiveUsers <= 50000) {
    baseCost = 0; // 免費
  } else if (monthlyActiveUsers <= 100000) {
    const billableUsers = monthlyActiveUsers - 50000;
    baseCost = billableUsers * 0.0055;
  } else if (monthlyActiveUsers <= 1000000) {
    baseCost = 50000 * 0.0055 + (monthlyActiveUsers - 100000) * 0.0046;
  } else {
    baseCost =
      50000 * 0.0055 +
      900000 * 0.0046 +
      (monthlyActiveUsers - 1000000) * 0.0025;
  }

  // SMS 費用
  const smsCost = smsMessagesPerMonth * 0.05;

  // 進階安全功能費用
  const advancedSecurityCost = advancedSecurityEnabled
    ? monthlyActiveUsers * 0.05
    : 0;

  const totalMonthlyCost = baseCost + smsCost + advancedSecurityCost;

  return {
    baseCost,
    smsCost,
    advancedSecurityCost,
    totalMonthlyCost,
  };
}

// 範例 1: 免費額度內
const smallScale = calculateCognitoCost({
  monthlyActiveUsers: 30000,
  smsMessagesPerMonth: 500,
  advancedSecurityEnabled: false,
});

console.log('=== Small Scale (30K MAU) ===');
console.log('Base Cost: $' + smallScale.baseCost.toFixed(2));
console.log('SMS Cost: $' + smallScale.smsCost.toFixed(2));
console.log('Total: $' + smallScale.totalMonthlyCost.toFixed(2));

// 範例 2: 中型規模
const mediumScale = calculateCognitoCost({
  monthlyActiveUsers: 200000,
  smsMessagesPerMonth: 5000,
  advancedSecurityEnabled: true,
});

console.log('\n=== Medium Scale (200K MAU) ===');
console.log('Base Cost: $' + mediumScale.baseCost.toFixed(2));
console.log('SMS Cost: $' + mediumScale.smsCost.toFixed(2));
console.log('Advanced Security: $' + mediumScale.advancedSecurityCost.toFixed(2));
console.log('Total: $' + mediumScale.totalMonthlyCost.toFixed(2));

// 範例 3: 大型規模(與自建比較)
const largeScale = calculateCognitoCost({
  monthlyActiveUsers: 1000000,
  smsMessagesPerMonth: 20000,
  advancedSecurityEnabled: true,
});

console.log('\n=== Large Scale (1M MAU) ===');
console.log('Cognito Total: $' + largeScale.totalMonthlyCost.toFixed(2));

// 自建成本估算
const selfHostedCost = {
  ec2: 100, // t3.medium x 3
  rds: 200, // db.t3.medium
  elasticache: 50, // cache.t3.micro
  dataTransfer: 100,
  engineeringTime: 2000, // 1 個工程師維護
};

const selfHostedTotal = Object.values(selfHostedCost).reduce((a, b) => a + b, 0);

console.log('Self-hosted Total: $' + selfHostedTotal.toFixed(2));
console.log('\n💡 Break-even point: ~100K MAU (without engineering cost)');
console.log('💡 With engineering cost: Cognito is cheaper up to 1M MAU');

/**
 * 成本優化策略:
 *
 * 1. 減少 MAU
 *    ✅ 只在必要時觸發身份操作
 *    ✅ 延長 Token 有效期(減少刷新)
 *    ✅ 使用本地快取
 *
 * 2. 減少 SMS 費用
 *    ✅ 優先使用 TOTP (免費)
 *    ✅ Email MFA 代替 SMS
 *    ✅ 只對高風險操作要求 MFA
 *
 * 3. 進階安全功能
 *    ✅ 評估是否真的需要
 *    ✅ 只對關鍵用戶群啟用
 *    ✅ 使用自建的風險評估
 *
 * 4. 混合策略
 *    ✅ B2C 用戶: Cognito (< 50K 免費)
 *    ✅ B2B 企業: 自建 (客製化需求)
 *    ✅ 內部用戶: SSO (SAML/OIDC)
 */

今日總結

我們今天完成了 AWS Cognito 的完整整合:

核心成就

  1. User Pool 建立: CDK 完整配置
  2. Lambda Triggers: 自動化用戶管理
  3. 社交登入: Google/Facebook 整合
  4. MFA 支援: SMS + TOTP
  5. 後端整合: Cognito SDK 封裝
  6. 混合認證: Cognito + 自建 JWT
  7. 成本分析: 與自建方案比較

技術深度分析

Cognito vs 自建何時選擇?:

  • < 50K MAU: Cognito 免費,明顯優勢
  • 50K - 500K MAU: Cognito 仍划算(含工程成本)
  • > 1M MAU: 自建成本優勢顯現
  • 💡 建議:混合策略最靈活

Lambda Triggers 用途:

  • Pre Sign Up: Email 域名驗證、黑名單檢查
  • Post Confirmation: 建立租戶、發送歡迎郵件
  • Pre Authentication: IP 檢查、異常登入偵測
  • Custom Message: 自訂郵件/SMS 內容
  • 💡 關鍵:整合後端業務邏輯

Token 管理策略:

  • Access Token: 1 小時(Cognito 預設)
  • ID Token: 1 小時
  • Refresh Token: 30 天
  • 💡 Cognito Token 無法撤銷(除非刪除用戶)

進階安全功能值得嗎?:

  • 提供:風險評分、異常偵測、妥協認證保護
  • 成本:每 MAU $0.05(翻倍成本)
  • 💡 建議:高價值用戶(金融、醫療)才啟用

Cognito 檢查清單

  • ✅ User Pool 建立與配置
  • ✅ 密碼政策設定
  • ✅ MFA 啟用(SMS + TOTP)
  • ✅ Lambda Triggers 設定
  • ✅ 社交登入整合
  • ✅ Hosted UI 自訂
  • ✅ 後端 SDK 整合
  • ✅ JWT Token 驗證
  • ✅ 混合認證策略
  • ✅ 成本監控告警

上一篇
Day 24: 30天部署SaaS產品到AWS-AWS SES 整合與郵件送達率優化完全指南
系列文
30 天將工作室 SaaS 產品部署起來25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言