Skip to content
Published on

同時ログイン防止実装ガイド — IP・セッション・JWT・Nginxレイヤー別戦略と実践コード

Authors

はじめに

1つのアカウントで複数の場所から同時にログインすることを防ぐ必要がある場面は、思ったよりも頻繁に発生します。金融サービス、有料ストリーミング、B2B SaaS、社内管理ツールなど、「1アカウント=1セッション」ポリシーが必須のドメインが多いためです。

本記事では同時ログイン防止(Concurrent Session Control)を実装するための4つのアプローチをアーキテクチャの観点から比較し、各レイヤーで何をチェックすべきか、そして実践コードと運用ノウハウを1記事にまとめます。


1. アプローチ比較

1-1. IPベース制限

最も単純な方法は「同じアカウントが異なるIPから接続したらブロック」することです。

利点

  • 実装が非常にシンプルです — ログイン時にIPを記録し、リクエストIPと比較するだけです。
  • 専用ストレージなしでも動作可能です。

欠点 — 誤検知(false positive)が深刻です

シナリオ結果
企業NAT環境 — 数百人が同じグローバルIPを使用異なるユーザーを同じユーザーと誤判定
モバイル回線切替(Wi-Fi → LTE)正常ユーザーがIP変更で強制ログアウト
プロキシ・VPNユーザー同一IPで多数ユーザーが接続、または1ユーザーのIPが頻繁に変動
CGNAT(通信事業者共有IP)全く無関係のユーザーが同じIP

結論:IPベース制限は**補助指標(risk signal)**としてのみ使用し、単独ポリシーとしては不適切です。「同一IPから短時間に複数アカウントのログイン試行」のようなブルートフォース検知には有効です。

1-2. セッションベースのシングルログイン

サーバーセッションストア(通常Redis)でユーザーごとのアクティブセッションを管理する方式です。

動作フロー

1. ログイン → セッション作成 → Redis: user:{userId}:sessions = {sessionId}
2. 新規ログイン発生 → 既存セッションID照会 → 既存セッション無効化(削除)
3. 既存デバイスからリクエスト → セッションなし → 401 + "別の場所でログインされました"メッセージ

利点

  • サーバーがセッションライフサイクルを完全に制御します。
  • 即時強制ログアウトが可能です。
  • 最大同時セッション数をN個に柔軟に設定できます。

欠点

  • サーバーサイドセッションストア(Redisなど)が必須です。
  • ステートレスアーキテクチャと衝突する可能性があります。
  • セッションストア障害時に全認証に影響します。

1-3. JWTベースのシングルログイン

ステートレスJWTを使用しながら同時ログインを制御するには、サーバー側の状態を最小限に維持する戦略が必要です。

主要技法

技法説明
jti(JWT ID)ベースのブラックリストログイン時に以前のトークンのjtiをブラックリストに追加。検証時にブラックリスト確認
session_versionユーザーテーブルにsession_versionカラム → ログイン時に増加 → JWTクレームと比較
device_idバインディングデバイス固有IDをJWTに含め、サーバーで許可デバイスリストを管理
Refresh Token Rotation新規ログイン時に既存のrefresh token familyを廃棄 → access tokenの更新不可

Refresh Token Rotationフロー

1. ログイン → access_token + refresh_token発行
2. refresh_tokenをサーバーDB/Redisに保存(user_id → token_family)
3. 新デバイスログイン → 新token_family発行 + 既存family全体廃棄
4. 既存デバイスがrefresh試行 → familyが廃棄済み → 401 + 再ログイン誘導

ポイント:JWTを使用しても、refresh tokenのサーバー側保存はほぼ必須です。完全なステートレスでは同時ログイン制御は不可能です。

1-4. デバイスバインディング / リスクベース認証

User-Agent、IP、ブラウザフィンガープリントなどを組み合わせてデバイスプロファイルを作成し、未知のデバイスからのログインに追加認証を要求する方式です。

  • 利用可能シグナル:User-Agent、IP帯域、画面解像度、タイムゾーン、インストール済みフォント、Canvas/WebGLフィンガープリント
  • 注意事項:ブラウザフィンガープリンティングはプライバシー侵害の恐れがあるため、同意ベースでのみ収集すべきです。GDPR、個人情報保護法などの法的要件を必ず確認してください。
  • 推奨パターン:強制ブロックではなく**「新デバイスからのログイン時にメール/SMS二次認証」**の形で使用します。

2. アプローチ総合比較

基準IPベースセッションベースJWT + Refresh Rotationデバイスバインディング
精度低い(NAT/VPN誤検知)高い高い中程度(フィンガープリント変動)
即時強制ログアウト不可可能Access期限切れまで遅延ポリシーによる
サーバー状態必要最小Redis/DB必須Refreshストア必要デバイスDB必要
マルチデバイス制御困難N個制限可能Family別管理デバイス別管理
実装難易度簡単中程度中程度〜高い高い
プライバシーリスク低い低い低い高い(フィンガープリント)

実務推奨組み合わせセッションベースまたはJWT + Refresh Token Rotationをメインに、IPとデバイス情報は補助risk signalとして活用します。


3. Nginxレベルチェック — できることと限界

3-1. Nginxが得意なこと

# Rate Limiting — 同一IPからの過度なリクエストを制限
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;

server {
    location /api/auth/login {
        limit_req zone=login burst=10 nodelay;
        proxy_pass http://backend;
    }
}
# IPベースのアクセス制御
geo $blocked {
    default 0;
    10.0.0.0/8 0;      # 内部ネットワーク許可
    192.168.0.0/16 0;   # 内部ネットワーク許可
    # 特定の悪意あるIPをブロック
    203.0.113.50 1;
}

server {
    if ($blocked) {
        return 403;
    }
}
# 国別ブロック(GeoIP2モジュール)
geoip2 /etc/nginx/GeoLite2-Country.mmdb {
    $geoip2_country_code country iso_code;
}

map $geoip2_country_code $allowed_country {
    KR 1;
    US 1;
    JP 1;
    default 0;
}

3-2. Nginxの限界

NginxはL7リバースプロキシであるため、以下のことはできません。

  • ユーザーセッションの意味解釈:「このリクエストがuserId=123の2番目のセッションか」の判断は不可能
  • JWTクレームベースの高度なロジック:トークンパースは可能ですが、DB/Redis照会ベースのセッション有効性検証は不可能
  • リアルタイムセッション無効化:特定ユーザーのセッションを即座に切断する動作はアプリケーション層の役割

3-3. njs / Lua / OpenResty拡張

拡張方式利点欠点
njs(Nginx JavaScript)Nginx公式サポート、軽量Redis連携制限、非同期IO不足
Lua(lua-nginx-module)非常に柔軟、Redis直接連携OpenResty必要、複雑度増加
OpenRestyLua + Nginx統合、高性能別途ビルド/管理、標準Nginxとの互換性
-- OpenResty + Lua例:JWTからuser_idを抽出しRedisセッション検証
local jwt = require "resty.jwt"
local redis = require "resty.redis"

local token = ngx.var.http_authorization:sub(8) -- "Bearer "を除去
local jwt_obj = jwt:load_jwt(token)

if jwt_obj.valid then
    local red = redis:new()
    red:connect("127.0.0.1", 6379)
    local active_session = red:get("user:" .. jwt_obj.payload.sub .. ":session")
    if active_session ~= jwt_obj.payload.jti then
        ngx.status = 401
        ngx.say('{"error": "session_expired", "message": "別の場所でログインされました"}')
        return ngx.exit(401)
    end
end

最終推奨ポリシー判断はアプリケーション/認証サーバーで行い、Nginxは**一次防御(rate limit、IPブロック、基本ゲーティング)**の役割に集中します。OpenResty/Lua拡張は認証サーバー前段のキャッシュ/軽量検証用途にのみ使用してください。


4. 実装コード例

4-1. Spring Boot — Concurrent Session Control

Spring Securityは同時セッション制御を標準で提供しています。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement(session -> session
                .maximumSessions(1)                    // 最大1セッション
                .maxSessionsPreventsLogin(false)        // false: 既存セッション期限切れ(デフォルト)
                                                       // true: 新規ログインブロック
                .expiredSessionStrategy(event -> {
                    HttpServletResponse response = event.getResponse();
                    response.setStatus(401);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"session_expired\",\"message\":\"別の場所でログインされました\"}"
                    );
                })
            );
        return http.build();
    }

    // 分散環境ではSpring Session + Redisを使用
    @Bean
    public SessionRegistry sessionRegistry() {
        return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);
    }

    @Autowired
    private FindByIndexNameSessionRepository<?> sessionRepository;
}

分散環境の必須依存関係

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

4-2. Django — セッションテーブル + Redisミドルウェア

# middleware.py
import redis
from django.conf import settings
from django.contrib.auth import logout

r = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0, decode_responses=True)

ACTIVE_SESSION_PREFIX = "user:active_session:"

class SingleSessionMiddleware:
    """1アカウントに1セッションのみ許可するミドルウェア"""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated:
            key = f"{ACTIVE_SESSION_PREFIX}{request.user.id}"
            current_session = request.session.session_key
            active_session = r.get(key)

            if active_session and active_session != current_session:
                # 現在のセッションがアクティブセッションではない → 強制ログアウト
                logout(request)
                from django.http import JsonResponse
                return JsonResponse(
                    {"error": "session_expired",
                     "message": "別の場所でログインされました"},
                    status=401
                )

        return self.get_response(request)
# signals.py — ログイン時にアクティブセッションを登録
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver

@receiver(user_logged_in)
def register_active_session(sender, request, user, **kwargs):
    key = f"user:active_session:{user.id}"
    # 既存セッションを削除(Djangoセッションストアから)
    old_session_key = r.get(key)
    if old_session_key:
        from django.contrib.sessions.models import Session
        Session.objects.filter(session_key=old_session_key).delete()

    # 新しいセッションを登録
    r.set(key, request.session.session_key, ex=settings.SESSION_COOKIE_AGE)

4-3. JWT — Refresh Token Rotation実装

// Node.js / Express例
const redis = require('ioredis')
const jwt = require('jsonwebtoken')
const crypto = require('crypto')

const client = new redis()

async function login(userId, deviceId) {
  const tokenFamily = crypto.randomUUID()
  const jti = crypto.randomUUID()

  // 既存token familyを廃棄
  const oldFamily = await client.get(`user:${userId}:token_family`)
  if (oldFamily) {
    await client.del(`token_family:${oldFamily}`)
  }

  // 新token familyを登録
  await client.set(`user:${userId}:token_family`, tokenFamily, 'EX', 86400 * 7)
  await client.set(
    `token_family:${tokenFamily}`,
    JSON.stringify({
      userId,
      deviceId,
      jti,
      rotationCount: 0,
    }),
    'EX',
    86400 * 7
  )

  const accessToken = jwt.sign({ sub: userId, jti, device_id: deviceId }, process.env.JWT_SECRET, {
    expiresIn: '15m',
  })

  const refreshToken = jwt.sign(
    { sub: userId, family: tokenFamily, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  return { accessToken, refreshToken }
}

async function refresh(refreshToken) {
  const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)
  const familyData = await client.get(`token_family:${decoded.family}`)

  if (!familyData) {
    // Token familyが廃棄済み → 窃取疑い → 全セッション無効化
    await client.del(`user:${decoded.sub}:token_family`)
    throw new Error('TOKEN_FAMILY_REVOKED')
  }

  const family = JSON.parse(familyData)

  // Rotation:新トークン発行 + 既存refresh無効化
  const newJti = crypto.randomUUID()
  family.jti = newJti
  family.rotationCount += 1

  await client.set(`token_family:${decoded.family}`, JSON.stringify(family), 'EX', 86400 * 7)

  const newAccessToken = jwt.sign(
    { sub: decoded.sub, jti: newJti, device_id: family.deviceId },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  )

  const newRefreshToken = jwt.sign(
    { sub: decoded.sub, family: decoded.family, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}

4-4. Nginx設定 — Rate Limit + 基本防御

# /etc/nginx/conf.d/security.conf

# ログインエンドポイントRate Limit
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/s;

# グローバル接続制限
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# 疑わしいIPマップ
map $remote_addr $is_suspicious {
    default 0;
    ~^192\.168\.100\. 1;  # 例:内部テスト帯域をブロック
}

server {
    listen 443 ssl;
    server_name api.example.com;

    # ログインエンドポイント
    location /api/auth/login {
        limit_req zone=auth_limit burst=10 nodelay;
        limit_conn conn_limit 10;

        if ($is_suspicious) {
            return 403;
        }

        proxy_pass http://backend;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # 認証必要API — アプリでセッション/トークン検証
    location /api/ {
        proxy_pass http://backend;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

5. アーキテクチャ整理 — 何をどこでチェックするか

┌────────────────────────────────────────────────┐
│                  クライアント                      │
│  (ブラウザ/アプリ — device_id, fingerprint生成)   │
└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
Nginx / L7 Proxy│  ✅ Rate Limit(loginブルートフォース防御)│  ✅ IPブロック(geo, deny)                       │
│  ✅ 基本ヘッダー検証                              │
│  ❌ セッション有効性判断(アプリの役割)            │
└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
│           認証サーバー / API Gateway│  ✅ JWT検証(署名、期限切れ、jtiブラックリスト)     │
│  ✅ session_version比較                           │
│  ✅ Refresh Token Rotation管理│  ✅ 同時セッション数ポリシー判断                    │
└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
│              セッション/トークンストア(Redis)      │
- user:{id}:sessions → Set of sessionId         │
- user:{id}:token_family → familyId             │
- token_family:{id}{userId, deviceId, jti}- blacklist:jti:{jti}TTL└──────────────────────────────────────────────────┘

6. 運用チェックリスト

6-1. 強制ログアウトUX

  • 明確な案内メッセージ:「別のデバイスからログインしたため、現在のセッションが終了しました」
  • 再ログイン誘導:ログインページにリダイレクト + 以前のページURL保持
  • リアルタイム通知:WebSocketまたはSSEで即座にセッション期限切れ通知(ポーリングよりUXが優れている)

6-2. 通知と監査ログ

  • 新しいデバイスログイン時にメール/プッシュ通知を送信
  • 監査ログ必須記録項目:timestampuser_idaction(login/logout/force_logout)ipuser_agentdevice_idsession_id
  • ログ保持期間はコンプライアンス要件に合わせる(金融:5年、一般:1〜3年)

6-3. 管理者機能

  • 管理者が特定ユーザーの全セッションを強制解除できるように実装
  • アカウントロック/解除機能と連携
  • 管理者操作も監査ログに記録

6-4. セキュリティおよびプライバシーの考慮

  • デバイスフィンガープリント収集時に明示的な同意が必要(個人情報保護法、GDPR)
  • IPアドレスは個人情報に該当 — ログ保管および処理時に法的根拠を確保
  • セッションID/トークンをログに平文で残さない(ハッシュ処理)
  • SecureHttpOnlySameSiteクッキー属性を必ず適用

7. トラブルシューティング

7-1. 正常ユーザーの誤検知による強制ログアウト

問題状況:モバイルユーザーがWi-Fi ↔ LTE切替時にIPが変わり、ログアウトされる

対応策

  1. IPを単独判断基準から除外 — セッション/トークンベースの検証を主力に使用
  2. Grace Periodの適用 — IP変更検知時に即座にブロックせず、5〜10分の猶予期間を付与
  3. デバイスIDを優先 — アプリならデバイスUUID、ウェブならlocalStorageのdevice_idを優先参照
  4. ユーザーフィードバックチャネル — 「本人でない場合は報告」+「本人です」再認証ボタン

事例:「会社のVPN接続/切断のたびにログアウト」

原因:VPN接続時にIPVPNゲートウェイIPに変更される
解決:IP変更だけではセッションを切断せず、refresh tokenが有効ならセッション維持
      + IP変更イベントを監査ログにのみ記録

7-2. マルチデバイス許可ポリシー設計

すべてのサービスが「シングルセッション」を要求するわけではありません。デバイスタイプ別の差別化ポリシーがより現実的です。

設計パターン:デバイスタイプ別スロット

{
  "concurrent_session_policy": {
    "max_total": 5,
    "per_device_type": {
      "web_browser": 2,
      "mobile_app": 2,
      "tablet_app": 1
    },
    "on_exceed": "expire_oldest",
    "admin_override": true
  }
}

実装ロジック

# 例:デバイスタイプ別スロット管理
def check_session_limit(user_id, device_type, new_session_id):
    key = f"user:{user_id}:sessions:{device_type}"
    sessions = redis.lrange(key, 0, -1)
    max_slots = POLICY['per_device_type'].get(device_type, 1)

    if len(sessions) >= max_slots:
        if POLICY['on_exceed'] == 'expire_oldest':
            oldest = redis.lpop(key)
            invalidate_session(oldest)  # 最も古いセッションを期限切れに
        elif POLICY['on_exceed'] == 'deny_new':
            raise SessionLimitExceeded()

    redis.rpush(key, new_session_id)
    redis.expire(key, SESSION_TTL)

ポリシー決定ポイント

決定項目選択肢
超過時の動作既存セッション期限切れ vs 新規ログインブロック
デバイスタイプ区分User-Agentパース vs クライアント明示伝達
管理者例外管理者アカウントは無制限 vs 同一ポリシー
VIP/プラン別差等Free: 1台、Pro: 3台、Enterprise: 無制限

8. 参考資料


まとめ

同時ログイン防止は単純に見えますが、どこで何をチェックするかによってアーキテクチャの複雑度とユーザー体験が大きく変わります。

核心原則をまとめると

  1. Nginxは一次防御 — Rate limitとIPブロックでノイズを除去します。
  2. ポリシー判断はアプリ/認証サーバー — セッション数制限、トークン有効性検証、強制ログアウトはアプリケーションの役割です。
  3. IPは補助指標 — 単独判断基準として使用すると誤検知が多いです。
  4. サーバー側の状態は不可避 — 完全なステートレスでは同時ログイン制御は不可能です。最低限refresh tokenストアが必要です。
  5. UXを忘れないでください — 強制ログアウト時に明確な案内と再ログイン動線がなければ、セキュリティポリシーがユーザー離脱につながります。

運用環境の規模、セキュリティ要件、ユーザーパターンに合わせて上記の戦略を組み合わせて適用してください。