バックエンド側 node.js CSRF/長時間処理/メール送信

2023-11-04

nodeそろそろ飽きてきたぞパクリ20発目!

パクリ元

内容

以下一覧を完了させて ECS リリースさせることでバックエンド側を終わりとする。順序は適宜入れ替える。

  • 開発環境構築
  • プロジェクト作成・最適なフォルダ構成
  • 認証チェック(含めた前処理) 
  • ログ処理
  • APIによってはIPチェックやHeaderチェック
  • バリデーション
  • 環境ファイルによる振り分け
  • DB参照・更新
  • 外部API呼び出し
  • 外部API呼び出し待ち合わせ
  • 認証・認可
  • SPA における CSRF CORS    ←←← 今回ココから。
  • 長時間処理を非同期処理で
  • メール送信
  • CSVアップロード
  • ファイルダウンロード
  • EXCEL/PDF作成
  • テスト
  • AWS環境へデプロイ

SPA における CSRF CORS

SPA における CSRF 対策が気になったのでパクリ元1つ目を確認。

CSRF はリクエストにブラウザが勝手に cookie を付与することに起因するので、

結局ブラウザの仕様の問題なんですよね、
まあトークン認証であれば、レスポンスで返すアクセストークンを手動でリクエストヘッダにつける方法なので問題ないと。
セッション認証の場合は、リクエストのオリジンヘッダーの適切なチェック(CORS対応)を行う+α の対策が必要と。パクリ元2つ目を参考にCORS対応やってみる。いまトークン認証になっている状態だけど問題はないでしょ。

~/nodetest$ npm install --save cors
~/nodetest$ npm install --save-dev @types/cors
~/nodetest/src/index.ts
・・・
import cors from 'cors';
・・・
// cors
const allowedOrigins = process.env.ALLOWED_ORIGIN?.split(',') || ['http://localhost:3001'];
const options: cors.CorsOptions = {
  origin: allowedOrigins
};
app.use(cors(options));
・・・
~/nodetest$ curl -X POST -H "Content-Type: application/json" -H "Origin: http://localhost:3002" -d '{"email": "alice@prisma.io", "password": "PASSWORD"}'  localhost:3001/user/login --dump-header -
HTTP/1.1 200 OK
Vary: Origin
・・・
{"email":"alice@prisma.io","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2VAcHJpc21hLmlvIiwiaWF0IjoxNjk4NTg3MjIxfQ.cVw-ZbAWIjQ_G7E_dkfo2yfLvYTjfXZdM9hV7mMvVqM"}
~/nodetest$ curl -X POST -H "Content-Type: application/json" -H "Origin: http://localhost:3001" -d '{"email": "alice@prisma.io", "password": "PASSWORD"}'  localhost:3001/user/login --dump-header -
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3001
Vary: Origin
・・・
{"email":"alice@prisma.io","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2VAcHJpc21hLmlvIiwiaWF0IjoxNjk4NTg3MjY3fQ.JNwoO71v2slLatcHY8TRzV9KP3eIutzeYzjGmOTGj18"}

「Access-Control-Allow-Origin:」というレスポンスヘッダが返ってきている。成功。というのが分からずハマってた。パクリ元3つ目にて

「CORSエラー = データが返ってこない」という大きな勘違いをしていました。
CORSエラーはブラウザ上で行われる判定であり、curlを叩いているターミナル上ではCORSエラーが存在しないのでAPIからJSON(レスポンスデータ)が普通に返ってきます。
「curlでデータが返ってくるからCORSエラーが発生しない = 動作が正常ではない」と勘違いしてハマりました。
レスポンスデータを見るのではなくレスポンスヘッダーを見るのでした。

とあったがまさにコレ。車輪の再発明でも周回遅れでもいいんです!
でもコレって POST で更新するときはレスポンスにデータ入れちゃダメってことか?普通はOK/NGの結果だけだから気にすることないか。

長時間処理を非同期処理で

コレ、他の言語だといったんリクエスト受けてからキューイングしといて違うプロセスから実行、とかいろいろややこしいはずなんだけど、、node.js だと基本非同期で動いているからまったく問題ない感じ。APIの認証は外している。

~/nodetest/src/routes/userRoute.ts
・・・
router.get("/list",
  // セッション認証用 authenticate,
  // トークン認証用 passport.authenticate("jwt", { session: false }),
  userController.list
);
・・・
router.post("/insertAsync", 
  userController.insertAsync, 
);
・・・
~/nodetest/src/controlles/userController.ts
・・・
    insertAsync: async(req: Request, res: Response) => {
        const { name, email, address, zipcode } = req.body;
        res.json(userModel.insertAsync(name, email, address, zipcode));
    },
・・・
~/nodetest/src/models/userModel.ts
・・・
    insertAsync: async (_name: string, _email: string, _address: string, _zipcode: string) => {
        const insertFunc = async () => {
            const user = prisma.user.create({
                data: {
                    name: _name,
                    email: new Date().toISOString()+_email,
                    password: "",
                    address: _address,
                    zipcode: _zipcode,
                },
            });
            return user;
        }
        const insertedUser: User = await dbaccess.execute(insertFunc);

        await new Promise(resolve => setTimeout(resolve, 10000));// 長時間処理(10秒)
        
        const updateFunc = async (insertedUser: User): Promise<any> => {
            const user = prisma.user.update({
                where: {
                    id: insertedUser.id,
                },
                data: {
                    zipcode: "*****",
                    latitude: 100,
                    longitude: 120,
                },
            });
            return user;
        }
        return dbaccess.execute(updateFunc, insertedUser);
    },
・・・
~/nodetest/src/core/dbaccess.ts
・・・
-    execute: async (func: Function) => {
+    execute: async (func: Function, arg: any = null) => {
・・・

最初に郵便番号と緯度経度以外を入れておいて10秒後に郵便番号と緯度経度を更新するパターン。レスポンスは郵便番号/緯度経度なしの時点で返ってくる。

~/nodetest$ npx prisma migrate reset
~/nodetest$ curl localhost:3001/user/list 
[{"id":1,"email":"alice@prisma.io","password":"・・・","name":"Alice","zipcode":null,"address":null,"latitude":null,"longitude":null}]

データは1件 Seeding によって投入されている。

~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name": "A", "email": "a@a.a", "address": "東京都"}' localhost:3001/user/insertAsync
{}
~/nodetest$ curlocalhost:3001/user/list 
[{"id":1,"email":"alice@prisma.io","password":"・・・","name":"Alice","zipcode":null,"address":null,"latitude":null,"longitude":null},{"id":2,"email":"2023-10-29T15:13:45.742Za@a.a","password":"","name":"A","zipcode":null,"address":"東京都","latitude":null,"longitude":null}]

非同期投入API でデータ投入後、データ一覧API を呼んだらデータは2件。郵便番号も緯度経度も null のまま。

~/nodetest$ curl localhost:3001/user/list 
[{"id":1,"email":"alice@prisma.io","password":"・・・","name":"Alice","zipcode":null,"address":null,"latitude":null,"longitude":null},{"id":2,"email":"2023-10-29T15:13:45.742Za@a.a","password":"","name":"A","zipcode":"*****","address":"東京都","latitude":100,"longitude":120}]

データ投入後10秒を超えた後にデータ一覧API を呼ぶと2件目のデータの郵便番号/緯度経度が更新されている。長時間処理の開始時に管理用のテーブルにデータ投入しておいて、終了時に結果をデータ更新するようにしておけば非同期処理の管理も簡単。

メール送信

パクリ元4つ目を参考にメール送信してみる。

~/nodetest$ npm install --save nodemailer
~/nodetest$ npm install --save-dev @types/nodemailer
~/nodetest/.env.development・~/nodetest/.env
・・・
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USER=
MAIL_PASS=
MAIL_SECURE=
~/nodetest/src/core/sendmail.ts
import { getLogger } from "log4js";
import { createTransport } from "nodemailer";

export default {
    send: (data: object) => {
        const transporter = createTransport({
            host: process.env.MAIL_HOST,
            port: Number(process.env.MAIL_PORT),
            secure: (process.env.MAIL_SECURE=="true"),
            auth: {
                user: process.env.MAIL_USER,
                pass: process.env.MAIL_PASS
            }
        });
        transporter.sendMail(data, (error: any, info: any) => {
            if (error) {
                getLogger("app").error(error);
            } else {
                getLogger("app").info(info);
            }
        });

    },
}
~/nodetest/src/models/userModel.ts
・・・
    insertAsync: async (_name: string, _email: string, _address: string, _zipcode: string) => {
        ・・・
        const insertedUser: User = await dbaccess.execute(insertFunc);

        // データ投入時メール送信
        const insertMail = {
            from: insertedUser.email,
            to: insertedUser.email,
            text: "投入完了",
            subject: '投入完了',
        };
        mail.send(insertMail);

        await new Promise(resolve => setTimeout(resolve, 10000));// 長時間処理(10秒)
        ・・・
        const updatedUser = dbaccess.execute(updateFunc, insertedUser);

        // データ更新時メール送信
        const updateMail = {
          from: insertedUser.email,
          to: insertedUser.email,
          text: "更新完了",
          subject: '更新完了',
        };
        mail.send(updateMail);
        
        return updatedUser;
    },
・・・

先ほどの長時間処理のメソッドの開始時と終了時にメール送信するように用意完了。

パクリ元5つ目を参考にテスト用のSMTPサーバーを docker で立ち上げる。docker 環境はローカル wsl に別に用意してあったのであっという間。こういう用意がないといろいろ止まってしまうことも多いだろうなと。

~$ docker run -d -p 1080:1080 -p 1025:1025 pocari/mailcatcher

curl で試してみる。

~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name": "A", "email": "a@a.a", "address": "東京都"}' localhost:3001/user/insertAsync
{}

メールは2通送信できていて、2通目は1通目の10秒後。問題なし。

現状のフォルダ構成

nodetest
┣━ config
┃  ┗━ log4js.json
┣━ dist
┣━ log
┃  ┣━ access.yyyymmdd.log
┃  ┗━ app.yyyymmdd.log
┣━ node_modules
┣━ prisma
┃  ┣━ migrations
┃  ┃   ┣━ yyyymmddhhmiss_create_user
┃  ┃   ┃   ┗━ migration.sql
┃  ┃   ┗━ migration_lock.toml
┃  ┣━ dev.db
┃  ┣━ schema.prisma
┃  ┗━ seed.ts
┣━ src
┃  ┣━ config
┃  ┃   ┣━ localStrategyWithSessionAuthenticate.ts
┃  ┃   ┗━ localStrategyWithTokenAuthenticate.ts
┃  ┣━ const
┃  ┃   ┗━ url.ts
┃  ┣━ controllers
┃  ┃   ┗━ userController.ts
┃  ┣━ core
┃  ┃   ┣━ dbaccess.ts
┃  ┃   ┣━ fetch.ts
┃  ┃   ┗━ sendmail.ts
┃  ┣━ middleware
┃  ┃   ┣━ addResponseHeader.ts
┃  ┃   ┣━ authenticate.ts
┃  ┃   ┣━ checkIpHeader.ts
┃  ┃   ┗━ errorHandler.ts
┃  ┣━ models
┃  ┃   ┣━ addressModel.ts
┃  ┃   ┗━ userModels.ts
┃  ┣━ routes
┃  ┃   ┣━ index.ts
┃  ┃   ┗━ userRoute.ts
┃  ┣━ types
┃  ┃   ┣━ authenticationError.ts
┃  ┃   ┗━ error.ts
┃  ┣━ validators
┃  ┃   ┗━ userUpdateValidator.ts
┃  ┗━ index.ts
┣━ .env
┣━ .env.development
┣━ .gitignore
┣━ package-lock.json
┣━ package.json
┗━ tsconfig.json

雑感

すこし先が見えてきた。というか飽きてきたので好奇心もそれほどわかない。