Security

JWTを用いたデバイスの同時実行制御:1ライセンス1台を実現する方法

JWTを用いたデバイスの同時実行制御:1ライセンス1台を実現する方法

SaaSやライセンス管理が必要なアプリケーションでは、「1つのライセンスで複数のデバイスから同時にログインされたくない」という要件がよくあります。この記事では、JWT(JSON Web Token)を使って、1ライセンスにつき1台のデバイスでのみ実行を許可する仕組みを実装します。

JWT Device Control

なぜデバイス制御が必要なのか?

よくある問題

  • ライセンスの不正共有: 1つのアカウントを複数人で使い回される
  • 収益の損失: 本来5ライセンス必要なところを1ライセンスで済まされる
  • セキュリティリスク: 意図しないデバイスからのアクセスを検知できない

解決策の要件

  1. デバイスの一意識別: 各デバイスを確実に識別できること
  2. リアルタイム検証: ログイン時に他のデバイスでの使用を即座に検知
  3. ユーザビリティ: 正規ユーザーの利便性を損なわないこと
  4. セキュリティ: デバイス情報の偽装を防ぐこと

Step 1: デバイスフィンガープリントの生成

デバイスを一意に識別するための「指紋」を作成します。

クライアント側(JavaScript/TypeScript)

// deviceFingerprint.ts import { createHash } from 'crypto'; interface DeviceInfo { userAgent: string; screenResolution: string; timezone: string; language: string; platform: string; } export function generateDeviceFingerprint(): string { const deviceInfo: DeviceInfo = { userAgent: navigator.userAgent, screenResolution: `${screen.width}x${screen.height}`, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, language: navigator.language, platform: navigator.platform }; // デバイス情報を結合してハッシュ化 const fingerprintString = Object.values(deviceInfo).join('|'); const hash = createHash('sha256').update(fingerprintString).digest('hex'); return hash; }

用語解説

  • User Agent: ブラウザやOSの情報を含む文字列
  • Screen Resolution: 画面の解像度(例: 1920x1080)
  • Timezone: タイムゾーン(例: Asia/Tokyo)
  • SHA256: 暗号学的ハッシュ関数。同じ入力からは常に同じ出力が得られる

Step 2: データベーススキーマの設計

アクティブなトークンとデバイス情報を保存します。

-- ActiveTokens テーブル CREATE TABLE ActiveTokens ( Id INT PRIMARY KEY IDENTITY(1,1), UserId INT NOT NULL, DeviceFingerprint NVARCHAR(64) NOT NULL, TokenJti NVARCHAR(36) NOT NULL, -- JWT の一意ID IssuedAt DATETIME2 NOT NULL, ExpiresAt DATETIME2 NOT NULL, LastActivity DATETIME2 NOT NULL, DeviceInfo NVARCHAR(MAX), -- JSON形式でデバイス詳細を保存 CONSTRAINT FK_ActiveTokens_Users FOREIGN KEY (UserId) REFERENCES Users(Id), INDEX IX_UserId (UserId), INDEX IX_DeviceFingerprint (DeviceFingerprint) );

スキーマのポイント

  • TokenJti: JWT の jti クレーム(JWT ID)と対応
  • LastActivity: 最終アクセス時刻を記録し、タイムアウト判定に使用
  • DeviceInfo: トラブルシューティング用に詳細情報を JSON で保存

Step 3: ログイン時のデバイスチェック

C# (.NET) での実装例

// LoginService.cs using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.EntityFrameworkCore; public class LoginService { private readonly AppDbContext _context; private readonly JwtService _jwtService; public async Task<LoginResult> LoginAsync( string username, string password, string deviceFingerprint) { // 1. ユーザー認証 var user = await AuthenticateUser(username, password); if (user == null) return LoginResult.Failed("Invalid credentials"); // 2. 既存のアクティブトークンをチェック var existingToken = await _context.ActiveTokens .Where(t => t.UserId == user.Id && t.ExpiresAt > DateTime.UtcNow) .FirstOrDefaultAsync(); if (existingToken != null) { // 同じデバイスからの再ログインなら許可 if (existingToken.DeviceFingerprint == deviceFingerprint) { // 既存トークンを延長 existingToken.LastActivity = DateTime.UtcNow; await _context.SaveChangesAsync(); return LoginResult.Success(existingToken.TokenJti); } else { // 別のデバイスからのログイン試行 return LoginResult.Failed( "This account is already in use on another device. " + "Please log out from the other device first." ); } } // 3. 新規トークンを発行 var jti = Guid.NewGuid().ToString(); var token = _jwtService.GenerateToken(user, jti); // 4. アクティブトークンを登録 var activeToken = new ActiveToken { UserId = user.Id, DeviceFingerprint = deviceFingerprint, TokenJti = jti, IssuedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(8), LastActivity = DateTime.UtcNow, DeviceInfo = GetDeviceInfoJson(deviceFingerprint) }; _context.ActiveTokens.Add(activeToken); await _context.SaveChangesAsync(); return LoginResult.Success(token); } }

フローの詳細解説

  1. ユーザー認証: パスワードの検証
  2. 既存トークンの確認: 同じユーザーIDで有効なトークンが存在するか
  3. デバイス照合:
    • 同じデバイス → 既存トークンを継続使用
    • 別のデバイス → ログイン拒否
  4. 新規登録: 初回ログインまたはトークン期限切れの場合

Step 4: API リクエストごとの検証

すべての保護されたエンドポイントで、トークンの有効性をチェックします。

// JwtAuthenticationMiddleware.cs public class JwtAuthenticationMiddleware { private readonly RequestDelegate _next; public async Task InvokeAsync( HttpContext context, AppDbContext dbContext) { var token = ExtractTokenFromHeader(context); if (token == null) { context.Response.StatusCode = 401; return; } var handler = new JwtSecurityTokenHandler(); var jwtToken = handler.ReadJwtToken(token); var jti = jwtToken.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; if (jti == null) { context.Response.StatusCode = 401; return; } // データベースでトークンの有効性を確認 var activeToken = await dbContext.ActiveTokens .FirstOrDefaultAsync(t => t.TokenJti == jti && t.ExpiresAt > DateTime.UtcNow); if (activeToken == null) { // トークンが無効化されている、または期限切れ context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { error = "Token has been revoked or expired" }); return; } // 最終アクティビティを更新 activeToken.LastActivity = DateTime.UtcNow; await dbContext.SaveChangesAsync(); await _next(context); } }

Step 5: 強制ログアウト機能

管理者や別デバイスからのログイン時に、既存セッションを強制終了します。

// LogoutService.cs public class LogoutService { private readonly AppDbContext _context; public async Task<bool> ForceLogoutAsync(int userId, string deviceFingerprint) { // 指定されたデバイス以外のトークンを削除 var tokensToRemove = await _context.ActiveTokens .Where(t => t.UserId == userId && t.DeviceFingerprint != deviceFingerprint) .ToListAsync(); if (tokensToRemove.Any()) { _context.ActiveTokens.RemoveRange(tokensToRemove); await _context.SaveChangesAsync(); return true; } return false; } public async Task LogoutCurrentDeviceAsync(string jti) { var token = await _context.ActiveTokens .FirstOrDefaultAsync(t => t.TokenJti == jti); if (token != null) { _context.ActiveTokens.Remove(token); await _context.SaveChangesAsync(); } } }

Step 6: クライアント側の実装

ログイン処理

// authService.ts export async function login(username: string, password: string) { const deviceFingerprint = generateDeviceFingerprint(); const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, deviceFingerprint }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } const { token } = await response.json(); localStorage.setItem('jwt_token', token); return token; }

API リクエスト時のトークン送信

// apiClient.ts export async function fetchProtectedData(endpoint: string) { const token = localStorage.getItem('jwt_token'); const response = await fetch(endpoint, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { // トークンが無効化されている localStorage.removeItem('jwt_token'); window.location.href = '/login?reason=session_expired'; return; } return response.json(); }

セキュリティ上の考慮事項

1. デバイスフィンガープリントの限界

問題点: ブラウザの設定変更や画面解像度の変更でフィンガープリントが変わる可能性がある。

対策:

  • 複数の識別要素を組み合わせる
  • 一定期間内の変更は許容する「ファジーマッチング」を実装
  • ユーザーに「信頼できるデバイス」として登録させる機能を追加

2. トークンの盗難対策

問題点: JWT が盗まれると、デバイスフィンガープリントも偽装される可能性がある。

対策:

  • HTTPS を必須にする
  • トークンの有効期限を短く設定(例: 8時間)
  • リフレッシュトークンを別途管理
  • IP アドレスの急激な変化を検知

3. データベースの負荷

問題点: すべてのリクエストでデータベースを確認するとパフォーマンスが低下する。

対策:

  • Redis などのインメモリキャッシュを使用
  • 最終アクティビティの更新頻度を制限(例: 5分に1回)
  • 読み取り専用レプリカを活用

実装例:Redis を使った高速化

// CachedTokenValidator.cs public class CachedTokenValidator { private readonly IDistributedCache _cache; private readonly AppDbContext _context; public async Task<bool> IsTokenValidAsync(string jti) { // まずキャッシュを確認 var cacheKey = $"token:{jti}"; var cached = await _cache.GetStringAsync(cacheKey); if (cached != null) { return cached == "valid"; } // キャッシュミスの場合はDBを確認 var isValid = await _context.ActiveTokens .AnyAsync(t => t.TokenJti == jti && t.ExpiresAt > DateTime.UtcNow); // 結果をキャッシュ(5分間) await _cache.SetStringAsync( cacheKey, isValid ? "valid" : "invalid", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) } ); return isValid; } }

よくある質問

Q1: ユーザーが新しいデバイスに切り替えたい場合は?

A: 明示的な「ログアウト」機能を提供し、古いデバイスからログアウトすることで新しいデバイスでログインできるようにします。または、管理画面で「すべてのデバイスからログアウト」機能を提供します。

Q2: デバイスフィンガープリントが変わってしまった場合は?

A: 以下の対策が考えられます:

  • メール認証による再ログイン
  • セキュリティ質問
  • 2要素認証(2FA)
  • 管理者による手動承認

Q3: モバイルアプリの場合は?

A: モバイルアプリでは、デバイスの一意識別子(iOS の identifierForVendor、Android の ANDROID_ID)を使用できます。これらはより安定しています。

まとめ

JWT を用いたデバイスの同時実行制御は、以下の要素を組み合わせることで実現できます:

  1. デバイスフィンガープリント: デバイスを一意に識別
  2. アクティブトークン管理: データベースで有効なトークンを追跡
  3. リアルタイム検証: API リクエストごとにトークンの有効性を確認
  4. 強制ログアウト: 必要に応じて他のデバイスのセッションを終了

この仕組みにより、ライセンスの不正利用を防ぎつつ、正規ユーザーには快適な体験を提供できます。

重要: セキュリティとユーザビリティのバランスを取ることが成功の鍵です。過度に厳しい制限は正規ユーザーの離脱を招く可能性があるため、ビジネス要件に応じて調整してください。