Node.jsとexpress-sessionを用いた単一デバイス・ブラウザでのアカウントログイン制限

同一アカウントの並行セッション制御の実装概要

本稿では、Node.js環境においてexpress-sessionとMicrosoft SQL Serverを連携させ、Webアプリケーション内での多重ログインを抑制する手法を示します。新規ログインリクエストが発生した時点で既存のセッションを無効化し、必ず最新の接続のみをアクティブにする仕様を実装します。

1. 依存パッケージの導入

セッション状態をRDBMSに永続化するため、以下のライブラリを導入します。

npm install express-session mssql connect-mssql-v2

※ connect-mssql-v2はexpress-session用の公式非対応ストアモジュールのうち、現状メンテナンスが継続されているものを選択しています。

2. テーブルスキーマの定義

セッションデータ保存用に専用テーブルを用意します。排他制御とクリーンアップ処理の効率化を図るため、有効期限カラムに複合インデックスを貼付することを推奨します。

CREATE TABLE [dbo].[ActiveSessions] (
    [sid] [nvarchar](255) NOT NULL PRIMARY KEY,
    [payload] [nvarchar](max) NOT NULL,
    [ttl] [datetime2] NOT NULL
);
CREATE NONCLUSTERED INDEX IDX_ActiveSessions_TTL ON [dbo].[ActiveSessions]([ttl]);

3. サーバー側セッション設定

データベース接続池とCookie署名用のシークレットキーを定義し、Expressミドルウェアパイプラインへ登録します。主要な動作制御パラメータは以下の通りです。

  • secret: Cookie内のセッション識別子を改ざん検知・暗号化するための鍵
  • saveUninitialized: 未使用の状態でもセッションCookieを出力するか否か。メモリ節約のためfalse固定
  • resave: 修正がなくても定期的にセーブするか否か。通常falseで性能劣化を防ぐ
  • cookie.maxAge: ブラウザ側で保持する有効期間(ms)
import express from 'express';
import session from 'express-session';
import SqlStore from 'connect-mssql-v2';

const server = express();

const connectionSpec = {
  user: process.env.SQL_UID,
  password: process.env.SQL_PWD,
  database: process.env.SQL_DB,
  server: '127.0.0.1',
  options: {
    encrypt: true,
    trustServerCertificate: false
  }
};

const storeParams = {
  table: 'ActiveSessions',
  client: null, // 後述の接続プール注入用
  ttl: 86400 // 秒単位での自動消去閾値
};

const sessionPersister = new SqlStore(connectionSpec, storeParams);

server.use(session({
  store: sessionPersister,
  secret: 'random-signature-sequence',
  name: 'browser_token',
  saveUninitialized: false,
  resave: false,
  cookie: { maxAge: 7200000, httpOnly: true, secure: true },
  proxy: true
}));

4. セッション切替と前 sesiion の破棄ロジック

express-sessionのストアインスタンスには、格納されたレコードを操作するAPIが公開されています。代表的な呼び出しパターンは以下の3点です。

  • persister.all(cb): 全レコードのスナップショット取得。結果は配列形式。
  • persister.get(id, cb): キー完全一致による単一レコード取得。
  • persister.destroy(id, cb): 指定キーのレコード物理削除。

4.1. 全件フィルタリング方式(all メソッド活用)

SIDをシステム自動採番するデフォルト構成で運用する場合、新規ログイン時に全セッション一覧を取得し、対象ユーザー名のデータを探し出して削除します。データ量が少ない開発段階や低トラフィック環境に適しています。

async function processStandardLogin(input: Credentials, request: Request) {
  try {
    const verifiedAccount = performAuthCheck(input);

    // ストアから全セッションを取得(Promiseラッパーで同期的に扱えるように整形)
    const snapshot = await fetchAllSessions(request);
    
    // 同一IDを保持する既存記録を掃引
    if (Array.isArray(snapshot)) {
      for (const recordId in snapshot) {
        const metaData = decodeSessionRecord(snapshot[recordId]);
        if (metaData.accountName === verifiedAccount.id) {
          await removeOldSession(request, recordId);
        }
      }
    }

    // 新たな認証ステートの作成
    return initiateFreshSession(request, verifiedAccount.id);

  } catch (ex) {
    throw new AuthenticationFailureException('Validation error');
  }
}

function fetchAllSessions(req: Request): Promise {
  return new Promise((done, fail) => {
    req.sessionStore.all((error, list) => error ? fail(error) : done(list ?? []));
  });
}

function removeOldSession(req: Request, sid: string): Promise<void> {
  return new Promise((done, fail) => {
    req.sessionStore.destroy(sid, error => error ? fail(error) : done());
  });
}

function initiateFreshSession(req: Request, id: string): Promise<object> {
  return new Promise((done, fail) => {
    req.session.regenerate((error) => {
      if (error) return fail(error);
      req.session.primaryIdentifier = id;
      req.session.save((saveErr) => saveErr ? fail(saveErr) : done({ status: 'active' }));
    });
  });
}

4.2. 一意キー割り当て方式(genid カスタマイズ)

高負荷環境や本番運用では、ストアへのアクセスコストを最小限に抑える設計が望まれます。そのためには、セッションIDそのものをユーザー識別子とし、genid関数で固定ルールを定義します。これによりall()による走査が不要になり、get()のみで即座に既存セッションの存在可否を確認できます。

// ルーター以前のパース完了段階でSID生成ルールを上書き
server.use(session({
  genid: (req) => {
    const caller = req.body?.accountId; 
    return caller ? `acct_${caller}` : undefined;
  },
  // ...その他オプション
}));

この構成を前提とした認証ハンドラーの実装例です。

async function optimizedLoginFlow(payload: LoginDto, request: Request) {
  try {
    const authorizedUser = executeCredentialVerification(payload);
    const candidateKey = `acct_${authorizedUser.handle}`;

    // 対象キーで直接検索
    const previousEntry = await querySpecificSession(request, candidateKey);

    // 既往セッションがあれば即時撤去
    if (previousEntry) {
      await destroySessionRecord(request, candidateKey);
    }

    // 新規接続の確立
    return establishSecureContext(request, authorizedUser.handle);

  } catch (err) {
    throw new SecurityException('Access denied');
  }
}

function querySpecificSession(req: Request, key: string): Promise<any> {
  return new Promise((resolve, reject) => {
    req.sessionStore.get(key, (err, data) => err ? reject(err) : resolve(data));
  });
}

function establishSecureContext(req: Request, uid: string) {
  return new Promise((resolve, reject) => {
    req.session.regenerate((error) => {
      if (error) return reject(error);
      req.session.userProfile = uid;
      req.session.save((persistErr) => persistErr ? reject(persistErr) : resolve({ auth: true }));
    });
  });
}

上記の実装により、クライアントが持つCookieの更新タイミングと連動してサーバー上のステートが一元管理されます。DB上のレコードは明示的な削除呼び出しか、設定されたTTLによるバッチクリーンアップによって維持され、セキュリティポリシーに沿った厳格なシングルセッション制御が達成されます。

タグ: express-session nodejs microsoft-sql-server single-login web-authentication

5月25日 13:30 投稿