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

【Express × PostgreSQL】初めての「認証付き CRUD API」をゼロから組むまでを、ちゃんと整理してみた【Node.js / TypeScript】

Posted at

これは、自分のためのふり返りメモです。

「Express と PostgreSQL を使って、ログインできる API サーバーを自力で作った」ときの道のりを、あとから読み返しても分かるように、ゆっくり言葉にしてみます。

未来の自分(ちょっと忘れた自分)が読んでも、「ああ、こうやって作ったんだったな」と思い出せるように。

はじめに

このメモでやりたいことは、ざっくりいうとこうです。

  1. Express という「Web サーバーのフレームワーク」を使う
  2. PostgreSQL(ポスグレ)という「データベース」につなぐ
  3. ユーザー登録(signup)とログイン(login)ができる
  4. ログインした人だけが使える /users の API を作る

つまり、
「ログイン機能付きのユーザー管理 API を、ゼロからちゃんと自分で組んでみる」
という練習です。


なぜ「Express + DB + 認証」を最初にやるのか

自分に向けて、まずこう言っておきたいです。
フロントエンドだけじゃなくて、「Web サービスの裏側」が分かるようになりたいなら、Express + DB + 認証 は最初に通っておくと後がラク。

理由をシンプルにまとめると:

  • どんな Web サービスも、だいたい
    • HTTP でリクエストが来る(Express の役目)
    • どこかの DB とかストレージに保存する(PostgreSQL の役目)
    • 誰がアクセスしているかを確かめる(認証 の役目)
  • この 3 つが分かると、「フロントから見えてない世界」が一気につながって見える

「裏側がどう動いているか」をイメージできるかどうかが大事
だから、最初にここをちゃんと通っておく価値は大きい、という話です。


ゴール:/auth/users がちゃんと動く API サーバー

このメモのゴールは、とてもはっきりしています。

  • /auth/signup
    • 名前・メール・パスワードを送ると、ユーザーが作られる
  • /auth/login
    • メール・パスワードを送ると、トークン(JWT)が返ってくる
  • /users / /users/:id
    • ヘッダーにトークンをつけてアクセスすると、ユーザー一覧や詳細が見られる
    • 更新・削除もできる

つまり、**「ログインしていない人は見られない /users API」**を Express と PostgreSQL で自分の手で作るのがゴールです。


全体像:このバックエンドは何でできているのか

ざっくり、こういう世界を作ります。

  1. クライアント(ブラウザや curl)がリクエストを送る
  2. Express がそのリクエストを受け取る
  3. Express から PostgreSQL に SQL を投げる
  4. 結果を JSON にして返す
  5. 認証が必要なものは、JWT トークンをチェックしてから進ませる

図:クライアント → Express → PostgreSQL の流れ

ファイル構成(index / app / routes / middleware / db)

ここで作ったときのファイル構成を、あとから見て分かるように整理しておきます。

  • src/index.ts
    • アプリの「入り口」。app.listen(PORT) とかを書く場所。
  • src/app.ts
    • Express の本体を組み立てる場所。app.use(express.json()) などの設定や、ルーターをつなぐ場所。
  • src/routes/auth.ts
    • /auth/signup/auth/login をまとめる。
  • src/routes/users.ts
    • /users/users/:id をまとめる。
  • src/middleware/authMiddleware.ts
    • JWT トークンをチェックする「門番」。
  • src/db.ts
    • PostgreSQL との接続設定(pg のプールなど)。
  • src/types/express.d.ts
    • req.user を型として追加するならここ。

こうやって役割ごとにファイルを分けることで、「どこに何が書いてあったか」をあとから思い出しやすくなります。


環境構築(Node / TypeScript / Docker / .env)

ざっくり、自分がやったことを順番に書くと:

  1. Node.js と npm をインストール
  2. npm init -y で Node プロジェクトを作る
  3. TypeScript を入れる
    • npm install --save-dev typescript ts-node-dev @types/node
  4. Express と関連パッケージを入れる
    • npm install express pg bcrypt jsonwebtoken
    • npm install --save-dev @types/express @types/bcrypt @types/jsonwebtoken
  5. PostgreSQL は Docker で立てる(あとで書く)
  6. .env を作って、DB の接続情報や JWT_SECRET をそこに書く

package.json と tsconfig.json の役割

ここも、説明すると:

  • package.json
    • → このプロジェクトで使う「部品(ライブラリ)一覧」と、「便利コマンド(scripts)」が書いてあるメモ帳。
  • tsconfig.json
    • → TypeScript に対して「こういうルールで書くからね」「出力はここに出してね」と伝えるための設定ファイル。

Docker で Postgres を立てる

PostgreSQL は、自分の PC に直接入れることもできるけど、今回は「Docker を使って箱の中に立てる」やり方でやりました。
docker-compose.yml にサービスを作っておけば、docker-compose up -d でポスグレが立ち上がります。

.env で接続情報と JWT_SECRET を管理する

DB のパスワードや JWT の秘密鍵などは、GitHub に上げたくないので .env に書いて、dotenv ライブラリで読みこむようにします。

DATABASE_URL=postgres://postgres:password@localhost:5432/myapp
JWT_SECRET=supersecretjwt
PORT=3000

DB 設計:users テーブルをどう設計するか

今回つくる API はユーザーを管理するので、まずは users テーブルをどうするか考えます。

必要なカラム

最低限、こんなカラムがほしいです。

  • id(主キー)… 自動でふられる番号
  • name … ユーザー名
  • email … ログインに使うメールアドレス(ユニーク)
  • password … ハッシュ化したパスワード
  • created_at / updated_at … 作成日時 / 更新日時

CREATE TABLE の SQL 例

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  password TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

「アプリの仕様」からテーブルを逆算するという考え方

ここで自分に言い聞かせたいポイントは、テーブル設計は、アプリの仕様から逆算するということ。

  • ログインさせたい → emailpassword が必要
  • ユーザー一覧を出したい → name はもちろん欲しい
  • あとから「いつ作ったユーザーか」を見たくなるかも → created_at を持っておく

というように、「このアプリで何をしたいか」から決めていくイメージです。


認証の設計:パスワードハッシュと JWT

なぜ平文保存してはいけないのか(bcrypt)

パスワードをそのまま保存すると、DB が漏洩したときに大事故になります。
なので、必ず**「ハッシュ化」**して保存します。今回は bcrypt を使いました。

  • bcrypt.hash(平文, ラウンド数) で、パスワードがわけの分からない文字列に変わる
  • ログインのときは bcrypt.compare(入力された平文, 保存されているハッシュ) で照合する

なぜ JWT を使うのか(ステートレス vs セッション)

ログイン処理のあと、「この人はログイン済みですよ」という証拠をどう扱うか。

  • セッション方式: サーバー側に「ログインした人のリスト」を覚えておく
  • JWT 方式: 「この人は userId=1 です」という情報をトークンの中に持たせる

今回は、サーバー側が状態を持たなくていい(ステートレス)JWT 方式を採用しました。


実装① authRouter:サインアップとログイン

POST /auth/signup の処理の流れ

  1. body から name, email, password を取り出す
  2. どれかが足りなければ 400 を返す
  3. 同じ email がすでにないか DB でチェックする
  4. bcrypt.hash でパスワードをハッシュ化する
  5. INSERT して、RETURNING id, name, email で新しいユーザー情報を返す
authRouter.post("/signup", async (req, res) => {
  const { name, email, password } = req.body;

  if (!name || !email || !password) {
    return res.status(400).json({ error: "name, email, password は必須です" });
  }

  // email 重複チェック
  const existing = await pool.query(
    "SELECT id FROM users WHERE email = $1",
    [email]
  );
  if (existing.rows.length > 0) {
    return res.status(409).json({ error: "このメールアドレスはすでに使われています" });
  }

  const hashedPassword = await bcrypt.hash(password, 10);

  const result = await pool.query(
    "INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email",
    [name, email, hashedPassword]
  );

  return res.status(201).json(result.rows[0]);
});

POST /auth/login の処理の流れ

  1. email, password を受け取る
  2. email でユーザーを検索する(見つからなければ 401)
  3. bcrypt.compare でパスワードをチェック
  4. OK なら jwt.sign でトークンを作る(有効期限は 24h にした)
  5. { token } を返す
authRouter.post("/login", async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: "email, password は必須です" });
  }

  const result = await pool.query(
    "SELECT id, password FROM users WHERE email = $1",
    [email]
  );
  if (result.rows.length === 0) {
    return res.status(401).json({ error: "メールアドレスまたはパスワードが違います" });
  }

  const user = result.rows[0];
  const ok = await bcrypt.compare(password, user.password);
  if (!ok) {
    return res.status(401).json({ error: "メールアドレスまたはパスワードが違います" });
  }

  const token = jwt.sign(
    { userId: user.id, email },
    process.env.JWT_SECRET!,
    { expiresIn: "24h" }
  );

  return res.json({ token });
});

【覚えるパターン】

  1. バリデーション
  2. DB 検索
  3. 比較(パスワードや重複)
  4. 成功したら最小限の情報を返す
    この 4 ステップは、いろんな API 実装でも同じパターンで出てきます。

実装② authMiddleware:JWT を検証する門番

Authorization: Bearer <token> のヘッダーをチェックする middleware です。
やっていることはシンプルです。

  1. ヘッダーからトークンを取り出す
  2. jwt.verify でチェックする
  3. OK なら req.user に詰める
  4. ダメなら 401 で返す
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

interface JwtPayload {
  userId: number;
  email: string;
}

export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return res.status(401).json({ error: "トークンが必要です" });
  }

  const [scheme, token] = authHeader.split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ error: "トークンの形式が正しくありません" });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    // 型定義してあればこんな感じで保存
    (req as any).user = { id: decoded.userId, email: decoded.email };
    next();
  } catch (err) {
    return res.status(401).json({ error: "トークンが無効です" });
  }
}

実装③ usersRouter:認証付き CRUD

authMiddleware を通したあとでだけアクセスできる /users を作ります。

usersRouter.use(authMiddleware);

// ユーザー一覧
usersRouter.get("/", async (req, res) => {
  const result = await pool.query(
    "SELECT id, name, email, created_at, updated_at FROM users ORDER BY id"
  );
  res.json(result.rows);
});

// 特定ユーザーの詳細
usersRouter.get("/:id", async (req, res) => {
  const id = Number(req.params.id);
  if (Number.isNaN(id)) {
    return res.status(400).json({ error: "id は数値で指定してください" });
  }

  const result = await pool.query(
    "SELECT id, name, email, created_at, updated_at FROM users WHERE id = $1",
    [id]
  );
  if (result.rows.length === 0) {
    return res.status(404).json({ error: "ユーザーが見つかりません" });
  }
  res.json(result.rows[0]);
});

// ユーザーの更新
usersRouter.put("/:id", async (req, res) => {
  const id = Number(req.params.id);
  const { name } = req.body;

  if (!name) {
    return res.status(400).json({ error: "name は必須です" });
  }

  const result = await pool.query(
    "UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, name, email, created_at, updated_at",
    [name, id]
  );

  if (result.rows.length === 0) {
    return res.status(404).json({ error: "ユーザーが見つかりません" });
  }

  res.json(result.rows[0]);
});

// ユーザーの削除
usersRouter.delete("/:id", async (req, res) => {
  const id = Number(req.params.id);
  const result = await pool.query("DELETE FROM users WHERE id = $1", [id]);

  if (result.rowCount === 0) {
    return res.status(404).json({ error: "ユーザーが見つかりません" });
  }

  // 204 No Content: 「ちゃんと消したけど、返す中身はないよ」
  res.status(204).send();
});

curl を使った実戦テスト例

フロントがなくても、curl で API をたたいて動作確認ができます。

1. signup

curl -X POST http://localhost:3000/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"kohei","email":"kohei@example.com","password":"password123"}'

2. login(トークンをもらう)

curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"kohei@example.com","password":"password123"}'

レスポンス例:

{ "token": "xxxxx.yyyyy.zzzzz" }

3. 認証付き /users

curl http://localhost:3000/users \
  -H "Authorization: Bearer xxxxx.yyyyy.zzzzz"

ここで 401 が返ってきたら、「トークンのつけ方」や「JWT_SECRET が合っているか」を疑います。


エラーを味方にする:実際に踏んだエラーと、どう直したか

ここが一番「自分に向けて言い聞かせたい」ところです。

password authentication failed for user "postgres"

DB の接続情報(ユーザー名・パスワード・DB 名)が .env と Docker 側でズレていると出るエラー。
対処: docker-compose.yml.env を見比べる。

relation "users" does not exist

users テーブルをまだ作っていないのにクエリを投げると出るエラー。
対処: CREATE TABLE を流したか確認する。

トークンなし・ヘッダ typo での 401

Authorization ヘッダーを入れ忘れたり、Bearer のスペルを間違えたりして 401 が出るやつ。
対処: console.log(req.headers) で、何が届いているか見る。

「エラー文を読む癖」が どれだけ重要か

エラーが出たときに、「うわ、エラーだ、どうしよう」ではなく、**「エラーはヒントだから、ちゃんと読む」**という反応ができるかどうか。

そのためには、まず自分がエラー文をちゃんと読む人になっておく必要があると感じました。


まとめ:次に何を足せば「プロダクション寄り」になるか

このメモのゴールは、「Express × PostgreSQL で、認証付きの CRUD API をとりあえず自分の手で組んで、全体像をつかむこと」でした。

ここから先、「もっとぽくする」ために足せるものを最後にリストアップしておきます。

  • 入力バリデーションの強化: zod などで、「どんな形の JSON なら OK か」を型として書く
  • ログ設計: winston などで、「いつ」「誰が」「どの API を叩いたか」をきちんと残す
  • マイグレーションツール: PrismaKnex で、テーブル変更をコードで管理する
  • テストと CI: Jest でテストを書き、GitHub Actions で回す

今の自分は、まず**「全体の流れをつかむ」「どこに何を書くか体で覚える」「エラーを怖がらない」**。
この 3 つができるようになるのが第一目標です。

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