はじめてのCognitoとExpressで実装するユーザー認証

# はじめに
Amazon Cognitoを利用して、Expressを使ったバックエンドアプリケーションに認証機能を実装する方法書きます。Amazon Cognitoは、ユーザーサインインやサインアップを簡単かつセキュアに行えるサービスで、ユーザー管理を手軽に実現できます。
TypeScriptでバックエンド開発したい！という方の参考になれば幸いです。

# 目次
- [はじめに](#はじめに)
- [必要な前提条件](#必要な前提条件)
- [該当リポジトリ](#該当リポジトリ)
- [ソースコード記述](#ソースコード記述)
- [AWSセットアップ](#awsセットアップ)
- [動作確認](#動作確認)
- [おわりに](#おわりに)

# 必要な前提条件
この記事を進めるには以下の準備が必要です。

- Node.jsとnpmのインストール
- AWSアカウントの作成

# 該当リポジトリ
https://github.com/mikaijun/cognito-express-onboarding

#  Expressのセットアップ
Node.js環境でExpressアプリケーションをセットアップします。
```
npm init -y
```
必要なライブラリをインストール
```
npm install aws-sdk dotenv express jsonwebtoken ts-node typescript
```
開発用の依存関係として型定義をインストールします。これらは開発中に型チェックを行うためのものです。
```
npm install --save-dev @types/express @types/jsonwebtoken
```
`tsconfig.json`を設定
```tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
```

# ソースコード記述
Cognitoの認証機能を利用して以下のエンドポイントを実装

- signUp: 新規ユーザーを登録
- confirmSignUp: ユーザーのメールアドレスを確認するための処理
- signIn: ユーザーがサインイン(ログイン)
- logout: アクセストークンを使用してユーザーをログアウト

```src/controllers/authController.ts
import AWS from 'aws-sdk';
import { Request, Response } from 'express';

const cognito = new AWS.CognitoIdentityServiceProvider();
const clientId = process.env.COGNITO_CLIENT_ID as string;

export const signUp = async (req: Request, res: Response) => {
  const { username, password, email } = req.body;

  const params = {
    ClientId: clientId,
    Username: username,
    Password: password,
    UserAttributes: [
      {
        Name: 'email',
        Value: email,
      },
    ],
  };

  try {
    const data = await cognito.signUp(params).promise();
    res.status(200).json({ message: 'ユーザー登録が成功しました', data });
  } catch (error) {
    res.status(400).json({ message: 'ユーザー登録に失敗しました', error });
  }
};

export const confirmSignUp = async (req: Request, res: Response) => {
  const { username, code } = req.body;

  const params = {
    ClientId: clientId,
    Username: username,
    ConfirmationCode: code,
  };

  try {
    const data = await cognito.confirmSignUp(params).promise();
    res.status(200).json({ message: 'ユーザー確認が成功しました', data });
  } catch (error) {
    res.status(400).json({ message: 'ユーザー確認に失敗しました', error });
  }
};

export const signIn = async (req: Request, res: Response) => {
  const { username, password } = req.body;

  const params = {
    AuthFlow: 'USER_PASSWORD_AUTH',
    ClientId: clientId,
    AuthParameters: {
      USERNAME: username,
      PASSWORD: password,
    },
  };

  try {
    const data = await cognito.initiateAuth(params).promise();
    res.status(200).json({ message: 'サインインが成功しました', data });
  } catch (error) {
    res.status(400).json({ message: 'サインインに失敗しました', error });
  }
};

export const logout = async (req: Request, res: Response) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    res.status(401).json({ message: 'トークンが提供されていません' });
    return;
  }

  const params = {
    AccessToken: token,
  };

  try {
    await cognito.globalSignOut(params).promise();
    res.status(200).json({ message: 'ログアウトが成功しました' });
  } catch (error) {
    res.status(400).json({ message: 'ログアウトに失敗しました', error });
  }
};
```
ユーザーの認証トークンを検証するためのミドルウェアを実装。ヘッダーに提供されたトークンの有効性を確認し、トークンが無効であれば401エラーを返す。
```src/middleware/authMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import { JwtPayload } from 'jsonwebtoken';
import AWS from 'aws-sdk';

export interface AuthenticatedRequest extends Request {
  user?: string | JwtPayload;
}

export const verifyToken = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(' ')[1];
  const cognito = new AWS.CognitoIdentityServiceProvider();

  if (!token) {
    res.status(401).json({ message: 'トークンが提供されていません' });
    return;
  }

  // Cognitoを使ってトークンの有効性を確認
  cognito.getUser({ AccessToken: token }, (err, data) => {
    if (err) {
      console.error('Cognito getUser error:', err);
      res.status(401).json({ message: 'トークンが無効です', error: err.message });
      return;
    }

    // ユーザー情報を`req.user`に格納して次のミドルウェアへ
    req.user = data;
    next();
  });
};
```
認証関連のルートを設定
```src/routes/auth.ts
import express from 'express';
import { Response } from 'express';
import { signUp, confirmSignUp, signIn, logout } from '../controllers/authController';
import { AuthenticatedRequest, verifyToken } from '../middleware/authMiddleware';

const router = express.Router();

router.post('/signup', signUp);
router.post('/confirm-signup', confirmSignUp);
router.post('/signin', signIn);
router.post('/logout', logout)
router.get('/protected', verifyToken, (req: AuthenticatedRequest, res: Response) => {
  res.status(200).json({ message: '保護されたルートにアクセスできました', user: req.user });
});

export default router;
```
Expressアプリケーションの設定
```src/app.ts
import 'dotenv/config';
import express from 'express';
import AWS from 'aws-sdk';
import authRoutes from './routes/auth';

const app = express();

app.use(express.json());

AWS.config.update({
  region: process.env.AWS_REGION,
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});

app.use('/auth', authRoutes);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
```

# AWSセットアップ
## Amazon Cognito
下記記事を参考にユーザープールの作成

https://techblog.insightedge.jp/entry/cognito-auth

IDは下記画像部分にあります。
![スクリーンショット 2024-10-22 14.20.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/48c6998a-f17c-2778-2c50-e32d82b4abcb.png)
![スクリーンショット 2024-10-22 15.35.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/0d67d924-a9a8-6f5a-50fd-c871ce5f9896.png)

## Amazon IAM
アクセスキーは下記記事を参考に作成
ローカル環境で試したい場合、ユースケースは「ローカルコード」でいいと思います

https://dev.classmethod.jp/articles/iam_user_create_access_keys_2023/

## .envセット
```.env
AWS_REGION=ap-northeast-1
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
COGNITO_USER_POOL_ID=xxx
COGNITO_CLIENT_ID=xxx
```

# 動作確認
サーバー起動
```
npx ts-node src/app.ts
```
ユーザー登録
```
curl -X POST http://localhost:3000/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"username": "xxx@gmail.co.jp", "password": "xxxxxxxxx", "email": "xxx@gmail.co.jp"}'
```
ユーザー認証。codeはユーザー登録時に入力したメール記載されてます
```
curl -X POST http://localhost:3000/auth/confirm-signup \
  -H "Content-Type: application/json" \
  -d '{"username": "xxx@gmail.co.jp", "code": "123456"}'
```
ログイン
```
curl -X POST http://localhost:3000/auth/signin \
  -H "Content-Type: application/json" \
  -d '{"username": "xxx@gmail.co.jp", "password": "xxxxxxxxx"}'
```

認証必要なAPIを実行する
```
curl -X GET http://localhost:3000/auth/protected \
  -H "Authorization: Bearer eyJraWQiOiJ..."
```
ログアウト
```
curl -X POST http://localhost:3000/auth/logout \  
  -H "Authorization: Bearer eyJraWQiOiJ..."
```

再度同じトークンで認証必要なAPIを実行してみる
```
curl -X GET http://localhost:3000/auth/protected \
  -H "Authorization: Bearer eyJraWQiOiJ..."
```
トークンが無効になっていることを確認
```
{"message":"トークンが無効です","error":"Access Token has been revoked"}
```

# おわりに
Amazon Cognitoを使えば簡単に認証することができました。
メールアドレス変更やパスワードリセットもAmazon Cognitoでできれば本格的な認証アプリ開発ができると思います。この記事が認証の参考になれば幸いです。
最後までお読みいただきありがとうございます！