Closed18

Fastify+PrismaでJWT認証付きREST-APIサーバーを作る

IGUCHI KanahiroIGUCHI Kanahiro

今現在、Node.jsでREST-APIを作ろうとすると以下のスタックが良さそうだと思った。

  • Fastify
  • Prisma
  • fastify-jwt

Fastifyの選定理由

  • Expressを普段から使っているが、Fastifyの方がパフォーマンスが良いらしい
  • NestJSも触ってみたが、ぼくのユースケースに対しては大げさすぎる気がした

なお、GraphQLを使うならmercuriusというのが良さそう。
(しかしRESTから乗り換えるほどの利点を感じなかった)

所感

  • FastifyはモダンなExpressという感じで癖はない。特に感動とかはないけど、TypeScriptの型サポートがいい感じ。
  • Prismaはとても開発体験がよい、すばらしい。
  • JWTは楽チン
  • fastify-swaggerがとてもいい感じなのだが、JSON-schemeを書かないとならないのがちょっとだるい。
IGUCHI KanahiroIGUCHI Kanahiro
  • Nginx
  • PostgreSQL
  • Fastify

上記をdocker-composeで構築する。

version: '3'
services:
  nginx:
    image: nginx
    ports:
        - '80:80'
    volumes:
        - ./web:/usr/share/nginx/html
        - ./nginx:/etc/nginx/conf.d
    depends_on:
      - fastify
    networks:
      - server
  fastify:
    build: ./api
    volumes:
      - ./api:/usr/src/app
    ports: 
      - 3000:3000
    tty: true
    command: bash -c "npm install && npm run dev"
    env_file:
      - .env
    networks:
      - server
  db:
    image: postgres:13.3
    environment:
      POSTGRES_USER: docker
      POSTGRES_PASSWORD: docker
    ports:
      - 5432:5432
    volumes:
      - db:/var/lib/postgresql
    networks:
      - server
volumes:
  db:
networks:
  server:
    driver: bridge
IGUCHI KanahiroIGUCHI Kanahiro

おなじみのTODOアプリを作ってみようと、下記のスキーマを書いてみる。

.prismaファイルは独自のシンタックスだが、それなりに直感的である
User:Taskが1:nの関係で、書き方はやや独特

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id       String @id @default(uuid())
  email    String @unique
  password String
  tasks    Task[]
}

model Task {
  id        String  @id @default(uuid())
  title     String
  completed Boolean @default(false)
  user      User    @relation(fields: [userId], references: [id])
  userId    String
}

seedデータはこんな感じ(なくてもいい)

import { hashSync } from 'bcrypt';

const userData: Prisma.UserCreateInput[] = [
    {
        email: 'test@test',
        password: hashSync('testpassword', 10),
        tasks: {
            create: [
                {
                    title: 'test',
                },
            ],
        },
    },
];
IGUCHI KanahiroIGUCHI Kanahiro

なお書き忘れていたがpackage.jsonは下記
必要ないのも混じっていたりします

{
    "name": "rest-fastify",
    "version": "1.0.0",
    "license": "MIT",
    "scripts": {
        "dev": "ts-node src/index.ts"
    },
    "dependencies": {
        "@fastify/cors": "^7.0.0",
        "@fastify/jwt": "^5.0.1",
        "@prisma/client": "3.14.0",
        "@sinclair/typebox": "^0.23.5",
        "@types/bcrypt": "^5.0.0",
        "bcrypt": "^5.0.1",
        "fastify": "3.29.0",
        "fastify-swagger": "^5.2.0"
    },
    "devDependencies": {
        "@types/node": "16.11.38",
        "prisma": "3.14.0",
        "ts-node": "10.8.0",
        "ts-node-dev": "^2.0.0",
        "typescript": "4.7.2"
    },
    "prisma": {
        "seed": "ts-node prisma/seed.ts"
    }
}

IGUCHI KanahiroIGUCHI Kanahiro

fastifyコンテナでPrismaをinit/migrate/seedしていく

npx prisma init
npx prisma migrate dev --name init
npx prisma db seed
IGUCHI KanahiroIGUCHI Kanahiro

雑にメインコードを貼り付ける
/signinのエラーハンドリングが雑なのはご愛敬

// ./src/index.ts
import { Prisma, PrismaClient } from '@prisma/client';
import fastify from 'fastify';
// @ts-ignore
import fastifySwagger from 'fastify-swagger';
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';

import routes from './routes';
import { compare } from 'bcrypt';

const app = fastify();
const prisma = new PrismaClient();
app.register(fastifyJwt, {
    secret: 'supersecret',
});

app.register(fastifySwagger, {
    routePrefix: '/docs',
    openapi: {},
    exposeRoute: true,
});
app.register(fastifyCors, {
    origin: 'localhost',
    methods: '*',
});

app.register(routes.users, { prefix: 'users' });
app.register(routes.tasks, { prefix: 'tasks' });

app.post<{ Body: { email: string; password: string } }>(
    '/signin',
    async (req, reply) => {
        const { email, password } = req.body;
        console.log(email, password);
        const user = await prisma.user.findUnique({
            where: {
                email,
            },
        });

        if (user === null) {
            reply.send({ error: '404' });
            return;
        }

        if (await compare(password, user.password)) {
            // some code
            const token = app.jwt.sign({ userId: user.id });
            reply.send({ token });
        } else {
            reply.send({ error: '401' });
        }
    },
);

app.listen(3000, '0.0.0.0', (err) => {
    if (err) {
        console.error(err);
        process.exit(1);
    }
    console.log(`
  🚀 Server ready at: http://localhost:3000
  ⭐️ See sample requests: http://pris.ly/e/ts/rest-fastify#3-using-the-rest-api`);
});

IGUCHI KanahiroIGUCHI Kanahiro

下記でセキュリティ的に問題ないかは未確認
ドキュメントには色々書いてあるので、もう少し正しいやり方がありそう
(この記事ではひとまず動けばええねん精神でお送りします)

JWTはこう初期化し

import fastifyJwt from '@fastify/jwt';

app.register(fastifyJwt, {
    secret: 'supersecret',
});

こう発行する

const token = app.jwt.sign({ userId: user.id });

期限などを設定する方法は調べていない(けど必ずある)

IGUCHI KanahiroIGUCHI Kanahiro

JWT認証をさせたいエンドポイントで下記のように書くと、トークンが付与されていない・不正だと401エラーとなる

    fastify.addHook('onRequest', async (request, reply) => {
        try {
            await request.jwtVerify();
        } catch (err) {
            reply.send(err);
        }
    });
IGUCHI KanahiroIGUCHI Kanahiro
import fastify, {
    FastifyInstance,
    FastifyRegisterOptions,
    RegisterOptions,
    FastifyPluginCallback,
} from 'fastify';
import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';

const prisma = new PrismaClient();

export const users: FastifyPluginCallback = (
    fastify: FastifyInstance,
    opts: FastifyRegisterOptions<RegisterOptions>,
    done: (err?: Error | undefined) => void,
) => {
    fastify.addHook('onRequest', async (request, reply) => {
        try {
            await request.jwtVerify();
        } catch (err) {
            reply.send(err);
        }
    });

    fastify.post<{
        Body: {
            email: string;
            password: string;
            tasks: Prisma.TaskCreateInput[];
        };
    }>(`/`, async (req, res) => {
        const { email, password, tasks } = req.body;

        const taskData = tasks?.map((task: Prisma.TaskCreateInput) => {
            return { title: task?.title };
        });

        const result = await prisma.user.create({
            data: {
                email,
                password,
                tasks: {
                    create: taskData,
                },
            },
        });
        res.send(result);
    });

    fastify.get('/', async (req, res) => {
        const users = await prisma.user.findMany({
            select: {
                id: true,
                email: true,
            },
        });
        res.send(users);
    });

    fastify.get<{ Params: { id: string } }>('/:id', async (req, res) => {
        const { id } = req.params;
        const users = await prisma.user.findUnique({
            where: {
                id,
            },
            select: {
                id: true,
                email: true,
            },
        });
        res.send(users);
    });

    fastify.delete<{ Params: { id: string } }>('/:id', async (req, res) => {
        const { userId } = await req.jwtDecode();
        const { id } = req.params;

        if (userId !== id) {
            res.send({ err: 401 });
        }

        const users = await prisma.user.delete({
            where: {
                id,
            },
        });
        res.send(users);
    });

    fastify.get<{ Params: { userId: string } }>(
        '/:userId/tasks',
        async (req, res) => {
            const { userId } = req.params;
            const task = await prisma.task.findMany({
                where: {
                    userId,
                },
            });
            res.send(task);
        },
    );

    fastify.post<{ Body: { title: string }; Params: { userId: string } }>(
        '/:userId/tasks',
        async (req, res) => {
            const { title } = req.body;
            const { userId } = req.params;
            const result = await prisma.task.create({
                data: {
                    title,
                    user: {
                        connect: {
                            id: userId,
                        },
                    },
                },
            });
            res.send(result);
        },
    );

    done();
};

IGUCHI KanahiroIGUCHI Kanahiro

./tasksエンドポイント

import fastify, {
    FastifyInstance,
    FastifyRegisterOptions,
    RegisterOptions,
    FastifyPluginCallback,
} from 'fastify';
import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';

const prisma = new PrismaClient();

export const tasks: FastifyPluginCallback = (
    fastify: FastifyInstance,
    opts: FastifyRegisterOptions<RegisterOptions>,
    done: (err?: Error | undefined) => void,
) => {
    fastify.addHook('onRequest', async (request, reply) => {
        try {
            await request.jwtVerify();
        } catch (err) {
            reply.send(err);
        }
    });

    fastify.put<{
        Params: { id: string };
        Body: { title?: string; completed?: boolean };
    }>('/:id', async (req, res) => {
        const { id } = req.params;
        const { title, completed } = req.body;
        const task = await prisma.task.update({
            data: {
                title,
                completed,
            },
            where: {
                id,
            },
        });
        res.send(task);
    });

    fastify.delete<{ Params: { id: string } }>('/:id', async (req, res) => {
        const { id } = req.params;
        const task = await prisma.task.findUnique({
            where: {
                id,
            },
        });

        if (task === null) {
            res.send({ err: 404 });
            return;
        }

        const { userId } = await req.jwtDecode();

        if (userId !== task.userId) {
            res.send({ err: 401 });
            return;
        }

        await prisma.task.delete({
            where: {
                id,
            },
        });
        res.send(task);
    });

    done();
};

IGUCHI KanahiroIGUCHI Kanahiro

以上のようにエンドポイントを個別ファイルに分割しておいて
下記のようにregister()することで、ルーティングごとにファイルを分割できましたとさ

import routes from './routes';

app.register(routes.users, { prefix: 'users' });
app.register(routes.tasks, { prefix: 'tasks' });
IGUCHI KanahiroIGUCHI Kanahiro

/signinで認証・JWTをもらう

curl -X POST -H 'Content-Type: application/json' -d '{"email":"test@test", "password":"testpassword"}' http://localhost:3000/signin

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI3MTYyNDQ0ZC1mOGVjLTRhMmMtYW以下略"}
IGUCHI KanahiroIGUCHI Kanahiro

/usersでユーザー一覧を受け取る
JWTをAuthorizationヘッダーに添える

curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI3MTYyNDQ0ZC1mOGVjLTRhMmMtYWMyNC1jZ以下略' http://localhost:3000/users

[{"id":"7162444d-f8ec-4a2c-ac24-cf7ffe1567e8","email":"test@test"}]
IGUCHI KanahiroIGUCHI Kanahiro

なおJWTがない場合はちゃんと401エラー

curl http://localhost:3000/users

{"statusCode":401,"error":"Unauthorized","message":"No Authorization was found in request.headers"}
IGUCHI KanahiroIGUCHI Kanahiro

JWT.ioでJWTを改竄してみると、ちゃんとエラー(userIdをいじってみた)

curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1以下略' http://localhost:3000/users

{"statusCode":401,"error":"Unauthorized","message":"Authorization token is invalid: The token signature is invalid."}
IGUCHI KanahiroIGUCHI Kanahiro

ちなみにfastify-swaggerを入れているので、http://localhost:3000/docsでOpenAPI準拠のAPIドキュメントが勝手に出来上がっている。ただし、上記コードの状態では、エンドポイントの種類以上の情報は表示されない。各エンドポイントでJSON-Schemeを書いてあげれば良いらしい。

IGUCHI KanahiroIGUCHI Kanahiro

以上でTODO管理ができそうなAPIサーバーができました。
次はフロントエンドを開発してTODOアプリを作ってみます。

このスクラップは2022/11/09にクローズされました