100本ノック / システム開発 / システム開発100本ノック
製造業の認証・権限管理入門|JWT・RBAC・API保護を学ぶ10本ノック
工場業務システムの「誰が・何をできるか」を守る — 認証・権限管理10本ノック
製造現場向けの作業実績・品質記録システムを題材に、認証と認可、ログイン、パスワード保護、JWT、フロントエンドのログイン状態、ログアウト、ロール設計、画面制御、API保護を一続きで整理します。目的は、ログイン機能を付けることではありません。作業者の操作性を保ちながら、品質判定の変更やマスタ更新などの重要操作を適切な担当者だけに限定し、監査可能な業務基盤を作ることです。
[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。
掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。
はじめに:この記事で扱う製造業の実務課題
製造業のシステムでは、同じ画面を作業者、班長、品質保証、システム管理者が利用することがあります。しかし、閲覧してよい情報と実行してよい操作は職務によって異なります。たとえば、作業者は自ラインの実績を登録できても、確定済みの品質判定やユーザー権限を変更できてはいけません。
今回の課題は、工場の作業実績・品質記録システムについて、次の3点を同時に成立させることです。
- 本人確認:操作しているのが登録済みの本人だと確認する
- 最小権限:職務・所属ライン・対象データに応じて必要な操作だけを許可する
- 業務継続:共有端末、交替勤務、通信遅延があっても安全に使い続けられるようにする
以降では、架空の利用者、ログイン試行、APIアクセス、セッションをPythonで生成し、設計上の判断を表とグラフで確認します。
現場でよくある状況
- 工場内の共有端末で前の利用者のログイン状態が残り、別人として実績を登録してしまう
- 「ログインできる人なら全機能を使える」という設計になり、品質判定やマスタを誰でも変更できる
- パスワードを平文または高速なハッシュだけで保存し、漏えい時の被害を拡大させる
- 画面上では管理ボタンを隠しているが、APIを直接呼ぶと操作できてしまう
- JWTの署名だけを確認し、有効期限、発行者、利用目的を確認していない
- 退職・異動・応援勤務後も古い権限やセッションが残る
- 認証失敗を一括して「エラー」と記録し、攻撃、操作ミス、アカウント停止を区別できない
なぜこの問題は判断が難しいのか
認証・権限管理は、セキュリティを強くするほどよいという一軸の問題ではありません。短い自動ログアウトはなりすましを減らす一方、手袋を外して何度もログインする負担を増やします。権限を細かく分けるほど最小権限へ近づきますが、異動や応援勤務のメンテナンスが複雑になります。
さらに、次の層を分けて考える必要があります。
- 本人確認:パスワードや多要素認証で本人かを確かめる
- セッション管理:確認済みという状態を期限付きで引き継ぐ
- 認可:その人が対象データへ操作してよいかを毎回判定する
- 監査:誰が、いつ、何を、許可または拒否されたかを追跡する
画面の使いやすさ、APIの安全性、人事・組織情報の更新、監査ログを一体で設計し、成功率だけでなく拒否理由や権限逸脱率もKPIとして追う必要があります。
今回扱うノックの全体像
| No. | テーマ | 工場業務システムでの確認点 |
|---|---|---|
| 051 | 認証と認可 | 本人確認と操作許可を分離する |
| 052 | ログイン画面 | 安全性と現場の完了率を両立する |
| 053 | パスワード | 平文保存を避け、salt付き低速ハッシュを使う |
| 054 | JWTの仕組み | 署名付きトークンの構造と検証項目を理解する |
| 055 | ログインAPI | 認証結果を一貫したAPI応答にする |
| 056 | フロント状態管理 | 未確認・確認中・認証済みを明示する |
| 057 | ログアウト | 端末側削除とサーバー側失効を設計する |
| 058 | ロール分離 | 管理者と一般利用者の操作を分ける |
| 059 | 画面切替 | 権限に合う導線だけを提示する |
| 060 | API保護 | すべての保護対象APIで認証・認可する |
Python環境の準備
numpyとpandasで架空データを生成・集計し、matplotlibで可視化します。JWTの構造確認とパスワードハッシュの例にはPython標準ライブラリを使います。乱数seedは固定し、再実行しても集計結果が変わらないようにします。
%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import sys
import json
import base64
import hashlib
import hmac
from datetime import datetime, timezone
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib
SEED = 6051
rng = np.random.default_rng(SEED)
pd.set_option("display.max_columns", 30)
pd.set_option("display.width", 140)
print(f"Python : {sys.version.split()[0]}")
print(f"numpy : {np.__version__}")
print(f"pandas : {pd.__version__}")
print(f"matplotlib : {matplotlib.__version__}")
print(f"random seed: {SEED}")
Python : 3.13.1
numpy : 2.5.1
pandas : 3.0.3
matplotlib : 3.11.0
random seed: 6051
架空データの作成
3ラインで働く120名の利用者、1,500件のログイン試行、2,400件のAPIアクセス、180件の有効セッションを作成します。利用者には作業者、班長、品質保証、管理者のロールを割り当てます。
ログには、パスワード誤り、MFA未完了、停止アカウント、期限切れトークン、署名不正、ロール不足、担当ライン外アクセスを意図的に含めます。これは実在する認証ログではなく、設計レビューとKPI計算のための架空データです。
roles = ["作業者", "班長", "品質保証", "管理者"]
role_prob = [0.63, 0.20, 0.12, 0.05]
n_users = 120
users = pd.DataFrame({
"user_id": [f"U{i:03d}" for i in range(1, n_users + 1)],
"role": rng.choice(roles, n_users, p=role_prob),
"home_line": rng.choice(["第1ライン", "第2ライン", "第3ライン"], n_users),
"account_status": rng.choice(["active", "locked", "disabled"], n_users, p=[0.92, 0.05, 0.03]),
"mfa_required": rng.random(n_users) < 0.28,
})
n_login = 1500
login_attempts = pd.DataFrame({
"attempt_id": [f"L{i:05d}" for i in range(1, n_login + 1)],
"attempted_at": pd.Timestamp("2026-06-01") + pd.to_timedelta(rng.integers(0, 30 * 24 * 60, n_login), unit="m"),
"user_id": rng.choice(users["user_id"], n_login),
"device": rng.choice(["共有タブレット", "事務所PC", "ハンディ端末"], n_login, p=[0.52, 0.28, 0.20]),
"password_ok": rng.random(n_login) < 0.94,
"mfa_completed": rng.random(n_login) < 0.965,
"duration_sec": np.maximum(3, rng.lognormal(mean=2.9, sigma=0.48, size=n_login)).round(1),
})
login_attempts = login_attempts.merge(users[["user_id", "account_status", "mfa_required"]], on="user_id", how="left")
login_attempts["failure_reason"] = np.select(
[
login_attempts["account_status"].eq("disabled"),
login_attempts["account_status"].eq("locked"),
~login_attempts["password_ok"],
login_attempts["mfa_required"] & ~login_attempts["mfa_completed"],
],
["停止アカウント", "ロック中", "パスワード不一致", "MFA未完了"],
default="成功",
)
login_attempts["success"] = login_attempts["failure_reason"].eq("成功")
endpoint_rules = pd.DataFrame({
"endpoint": ["GET /work-orders", "POST /results", "PUT /quality-judgments", "GET /audit-logs", "POST /users"],
"required_role": ["作業者以上", "作業者以上", "品質保証以上", "管理者", "管理者"],
"allowed_roles": [
roles,
roles,
["品質保証", "管理者"],
["管理者"],
["管理者"],
],
})
n_access = 2400
api_access = pd.DataFrame({
"request_id": [f"R{i:05d}" for i in range(1, n_access + 1)],
"user_id": rng.choice(users["user_id"], n_access),
"endpoint": rng.choice(endpoint_rules["endpoint"], n_access, p=[0.38, 0.31, 0.15, 0.09, 0.07]),
"target_line": rng.choice(["第1ライン", "第2ライン", "第3ライン"], n_access),
"token_present": rng.random(n_access) < 0.975,
"signature_valid": rng.random(n_access) < 0.992,
"token_expired": rng.random(n_access) < 0.035,
})
api_access = (api_access
.merge(users[["user_id", "role", "home_line", "account_status"]], on="user_id", how="left")
.merge(endpoint_rules[["endpoint", "allowed_roles"]], on="endpoint", how="left"))
api_access["identity_verified"] = (
api_access["token_present"] & api_access["signature_valid"] & ~api_access["token_expired"] & api_access["account_status"].eq("active")
)
api_access["role_allowed"] = api_access.apply(lambda r: r["role"] in r["allowed_roles"], axis=1)
api_access["scope_allowed"] = api_access["role"].isin(["品質保証", "管理者"]) | api_access["home_line"].eq(api_access["target_line"])
api_access["permission_granted"] = api_access["identity_verified"] & api_access["role_allowed"] & api_access["scope_allowed"]
n_sessions = 180
sessions = pd.DataFrame({
"session_id": [f"S{i:04d}" for i in range(1, n_sessions + 1)],
"user_id": rng.choice(users["user_id"], n_sessions),
"device": rng.choice(["共有タブレット", "事務所PC", "ハンディ端末"], n_sessions, p=[0.55, 0.27, 0.18]),
"age_min": rng.integers(1, 721, n_sessions),
"revoked": rng.random(n_sessions) < 0.08,
})
display(users.head())
print(f"利用者: {len(users):,}名 / ログイン試行: {len(login_attempts):,}件 / APIアクセス: {len(api_access):,}件 / セッション: {len(sessions):,}件")
| user_id | role | home_line | account_status | mfa_required | |
|---|---|---|---|---|---|
| 0 | U001 | 作業者 | 第1ライン | active | False |
| 1 | U002 | 作業者 | 第3ライン | locked | False |
| 2 | U003 | 班長 | 第3ライン | active | True |
| 3 | U004 | 作業者 | 第2ライン | active | True |
| 4 | U005 | 作業者 | 第2ライン | active | False |
利用者: 120名 / ログイン試行: 1,500件 / APIアクセス: 2,400件 / セッション: 180件
No.051:認証と認可の違いを理解する
実務での意味
**認証(Authentication)**は「誰であるか」の確認、**認可(Authorization)**は「その人が何をしてよいか」の判定です。ログイン済みの作業者でも、品質判定の確定やユーザー追加が許されるとは限りません。工場では、所属ラインや対象設備まで含めた認可が必要です。
分析・モデル化の考え方
API要求 の許可判定を、次の論理積で表します。
は本人確認済み、は必要ロールを保有、は対象ラインなどのスコープ内を表します。どれか1つでも偽なら拒否し、理由を監査ログへ残します。
Pythonで確認する
auth_funnel = pd.DataFrame({
"判定段階": ["全API要求", "本人確認済み", "必要ロールあり", "対象スコープ内", "最終許可"],
"件数": [
len(api_access),
int(api_access["identity_verified"].sum()),
int((api_access["identity_verified"] & api_access["role_allowed"]).sum()),
int((api_access["identity_verified"] & api_access["scope_allowed"]).sum()),
int(api_access["permission_granted"].sum()),
],
})
auth_funnel["全要求比_%"] = (auth_funnel["件数"] / len(api_access) * 100).round(1)
display(auth_funnel)
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(auth_funnel["判定段階"], auth_funnel["件数"], color="#2878B5")
ax.set_title("API要求の認証・認可ファネル")
ax.set_xlabel("判定段階")
ax.set_ylabel("要求件数")
ax.grid(axis="y", alpha=0.3)
plt.xticks(rotation=20)
plt.tight_layout()
plt.show()
| 判定段階 | 件数 | 全要求比_% | |
|---|---|---|---|
| 0 | 全API要求 | 2400 | 100.0 |
| 1 | 本人確認済み | 1903 | 79.3 |
| 2 | 必要ロールあり | 1325 | 55.2 |
| 3 | 対象スコープ内 | 905 | 37.7 |
| 4 | 最終許可 | 677 | 28.2 |
結果の読み取り
本人確認を通過しても、ロールまたは担当範囲の判定で拒否される要求があります。これは障害ではなく、最小権限が機能した結果です。実務では成功率だけを上げようとせず、「本人確認失敗」「ロール不足」「担当外」を分け、正当な業務が過剰に拒否されていないかを確認します。
No.052:ログイン画面を作成する
実務での意味
ログイン画面は本人確認の入口であると同時に、現場作業を始める入口です。共有端末では利用者IDの候補を残さない、パスワードを画面やログへ出さない、連続失敗を制御する、といった安全策が必要です。一方、MFAや再入力の負担が交替時の作業開始を遅らせないかも確認します。
分析・モデル化の考え方
端末別にログイン成功率、完了時間の中央値、95パーセンタイル、失敗理由を計測します。平均値だけでは、一部の利用者が長時間止まる問題を見落とすため、裾側の指標を併用します。
Pythonで確認する
login_kpi = (login_attempts.groupby("device")
.agg(
試行件数=("attempt_id", "size"),
成功率_pct=("success", lambda s: s.mean() * 100),
中央完了秒=("duration_sec", "median"),
p95完了秒=("duration_sec", lambda s: s.quantile(0.95)),
)
.round(1))
failure_counts = login_attempts.loc[~login_attempts["success"], "failure_reason"].value_counts()
display(login_kpi)
display(failure_counts.rename("件数").to_frame())
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(failure_counts.index, failure_counts.values, color="#D9534F")
ax.set_title("ログイン失敗理由の内訳")
ax.set_xlabel("失敗理由")
ax.set_ylabel("試行件数")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
| 試行件数 | 成功率_pct | 中央完了秒 | p95完了秒 | |
|---|---|---|---|---|
| device | ||||
| ハンディ端末 | 313 | 76.7 | 18.1 | 40.0 |
| 事務所PC | 411 | 79.3 | 18.4 | 39.2 |
| 共有タブレット | 776 | 79.5 | 18.1 | 41.5 |
| 件数 | |
|---|---|
| failure_reason | |
| ロック中 | 127 |
| 停止アカウント | 103 |
| パスワード不一致 | 73 |
| MFA未完了 | 14 |
結果の読み取り
失敗理由を分けると、パスワード入力支援で改善すべき問題と、管理者によるアカウント確認が必要な問題を区別できます。画面には攻撃者へ登録状況を推測させない一般的なメッセージを返しつつ、サーバー側の監査ログには詳細理由を残す設計が有効です。端末別のp95が長い場合は、端末配置やMFA手段も含めて見直します。
No.053:パスワードを安全に扱う
実務での意味
パスワードは復元できる形で保存しません。漏えい時に同じパスワードの利用者同士が一括して特定されないよう、利用者ごとのsaltを付け、総当たりを遅くする専用方式でハッシュ化します。通信はTLSで保護し、アプリログ、分析基盤、問い合わせ票へパスワードを残さないことも重要です。
分析・モデル化の考え方
このNotebookでは標準ライブラリのPBKDF2を仕組みの説明に使います。実システムでは、組織の標準と利用フレームワークが提供するArgon2id、scrypt、bcrypt、PBKDF2等の実装を選び、反復回数などを定期的に見直します。saltは秘密情報ではありませんが、利用者ごとに一意にします。
Pythonで確認する
def pbkdf2_hash(password: str, salt: bytes, iterations: int = 210_000) -> str:
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return base64.b64encode(digest).decode("ascii")
sample_password = "TrainingOnly-Example!"
password_demo = pd.DataFrame([
{"利用者": "U001", "salt": "factory-salt-001", "hash先頭": pbkdf2_hash(sample_password, b"factory-salt-001")[:20]},
{"利用者": "U002", "salt": "factory-salt-002", "hash先頭": pbkdf2_hash(sample_password, b"factory-salt-002")[:20]},
])
password_demo["同じパスワード"] = True
display(password_demo)
print("同一パスワードでもsaltが異なるため、保存されるハッシュは一致しません。")
| 利用者 | salt | hash先頭 | 同じパスワード | |
|---|---|---|---|---|
| 0 | U001 | factory-salt-001 | qfsoyzYFWurabZUVwCRL | True |
| 1 | U002 | factory-salt-002 | h7aDP+vuWGCBCrLNvu+1 | True |
同一パスワードでもsaltが異なるため、保存されるハッシュは一致しません。
結果の読み取り
同じパスワードでもsaltが異なるとハッシュ値は変わります。照合時は、保存済みsaltと同じ設定で入力値をハッシュ化し、定数時間比較を行います。サンプルの反復回数をそのまま本番標準とせず、実行環境の性能、採用方式、最新の社内基準に基づいて決めます。パスワードリセット時には本人確認、既存セッション失効、監査記録も必要です。
No.054:JWT認証の仕組みを理解する
実務での意味
JWTは、APIが利用者情報を受け渡すための署名付きトークンとして使われます。一般的なJWTはheader.payload.signatureの3部分で、payloadは暗号化ではなくBase64URL表現です。そのため、個人情報や秘密情報を安易に入れてはいけません。
分析・モデル化の考え方
APIは署名だけでなく、少なくとも有効期限exp、発行者iss、受信者aud、トークンIDjtiを検証します。JWTが有効でも、対象操作の認可は別途必要です。短いアクセストークンと、より厳格に管理する更新トークンの役割も分けます。
Pythonで確認する
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"sub": "U042", "role": "班長", "iss": "factory-auth",
"aud": "work-api", "iat": 1782860400, "exp": 1782861300, "jti": "demo-jti-001"
}
secret = b"not-for-production-demo-secret"
unsigned = f"{b64url(json.dumps(header, separators=(',', ':')).encode())}.{b64url(json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode())}"
signature = b64url(hmac.new(secret, unsigned.encode(), hashlib.sha256).digest())
token = f"{unsigned}.{signature}"
parts = pd.DataFrame({
"部分": ["header", "payload", "signature"],
"文字数": [len(x) for x in token.split(".")],
"役割": ["方式・種別", "利用者・期限等のclaims", "改ざん検知"],
})
display(parts)
print("JWT例(先頭80文字):", token[:80] + "...")
print("payload:", payload)
| 部分 | 文字数 | 役割 | |
|---|---|---|---|
| 0 | header | 36 | 方式・種別 |
| 1 | payload | 164 | 利用者・期限等のclaims |
| 2 | signature | 43 | 改ざん検知 |
JWT例(先頭80文字): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJVMDQyIiwicm9sZSI6IuePremVtyIsIml...
payload: {'sub': 'U042', 'role': '班長', 'iss': 'factory-auth', 'aud': 'work-api', 'iat': 1782860400, 'exp': 1782861300, 'jti': 'demo-jti-001'}
結果の読み取り
payloadの内容は復号鍵なしでも読めるため、JWTは「秘密の容器」ではありません。署名は改ざんを検知しますが、盗まれた有効トークンの悪用を自動的には防ぎません。TLS、短い有効期限、安全な保存場所、鍵のローテーション、必要に応じた失効管理を組み合わせます。アルゴリズムは受信トークン任せにせず、サーバー側で許可方式を固定します。
No.055:JWTを使ったログインAPIを作る
実務での意味
ログインAPIは資格情報を検証し、成功時だけトークンを発行します。失敗時にパスワード不一致と利用者未登録を外部へ細かく出し分けると、アカウント列挙へ使われる可能性があります。外向け応答と内部監査ログの情報量を分けます。
分析・モデル化の考え方
状態遷移は「入力検証 → レート制限 → 資格情報照合 → アカウント状態確認 → MFA → トークン発行」です。各段階の失敗率を測り、HTTPステータス、エラーコード、監査イベントを一貫させます。トークン本文へ過剰な権限情報を詰め込まず、変更反映の遅れも設計します。
Pythonで確認する
def mock_login_api(user_exists: bool, password_ok: bool, account_status: str, mfa_ok: bool) -> dict:
if not user_exists or not password_ok:
return {"status": 401, "public_code": "INVALID_CREDENTIALS", "token_issued": False}
if account_status != "active":
return {"status": 403, "public_code": "ACCOUNT_UNAVAILABLE", "token_issued": False}
if not mfa_ok:
return {"status": 401, "public_code": "MFA_REQUIRED", "token_issued": False}
return {"status": 200, "public_code": "OK", "token_issued": True}
login_api_cases = pd.DataFrame([
{"ケース": "正常", **mock_login_api(True, True, "active", True)},
{"ケース": "パスワード不一致", **mock_login_api(True, False, "active", True)},
{"ケース": "未登録ID", **mock_login_api(False, False, "active", True)},
{"ケース": "停止アカウント", **mock_login_api(True, True, "disabled", True)},
{"ケース": "MFA未完了", **mock_login_api(True, True, "active", False)},
])
display(login_api_cases)
| ケース | status | public_code | token_issued | |
|---|---|---|---|---|
| 0 | 正常 | 200 | OK | True |
| 1 | パスワード不一致 | 401 | INVALID_CREDENTIALS | False |
| 2 | 未登録ID | 401 | INVALID_CREDENTIALS | False |
| 3 | 停止アカウント | 403 | ACCOUNT_UNAVAILABLE | False |
| 4 | MFA未完了 | 401 | MFA_REQUIRED | False |
結果の読み取り
未登録IDとパスワード不一致を同じ外向けコードにすると、利用者の登録有無を推測されにくくできます。一方、運用担当が原因調査できるよう、内部には相関ID、時刻、端末、判定段階を記録します。応答本文へトークンを返す方式、Secure・HttpOnly・SameSite属性付きCookieを使う方式などは、ブラウザ構成と脅威モデルに合わせて選びます。
No.056:ログイン状態をフロントエンドで管理する
実務での意味
フロントエンドは、単純なloggedIn=True/Falseだけでなく、初期確認中、認証済み、期限切れ、更新中、認証失敗を区別します。初期確認中に管理画面を一瞬表示する、期限切れなのに古い利用者情報を残す、といった誤動作を避けるためです。
分析・モデル化の考え方
認証状態を有限状態機械として扱います。イベントに対して許される遷移を定義し、画面表示、副作用、次の操作を対応づけます。トークン自体を画面表示用状態と混同せず、保存場所と更新処理を限定します。
Pythonで確認する
transitions = {
("未確認", "アプリ起動"): "確認中",
("確認中", "セッション有効"): "認証済み",
("確認中", "セッションなし"): "未認証",
("認証済み", "期限接近"): "更新中",
("更新中", "更新成功"): "認証済み",
("更新中", "更新失敗"): "期限切れ",
("認証済み", "ログアウト"): "未認証",
}
events = ["アプリ起動", "セッション有効", "期限接近", "更新成功", "期限接近", "更新失敗"]
state = "未確認"
state_log = []
for event in events:
next_state = transitions.get((state, event), "不正遷移")
state_log.append({"現在状態": state, "イベント": event, "次状態": next_state})
state = next_state
state_log = pd.DataFrame(state_log)
display(state_log)
| 現在状態 | イベント | 次状態 | |
|---|---|---|---|
| 0 | 未確認 | アプリ起動 | 確認中 |
| 1 | 確認中 | セッション有効 | 認証済み |
| 2 | 認証済み | 期限接近 | 更新中 |
| 3 | 更新中 | 更新成功 | 認証済み |
| 4 | 認証済み | 期限接近 | 更新中 |
| 5 | 更新中 | 更新失敗 | 期限切れ |
結果の読み取り
状態遷移を明示すると、「確認中は保護画面を表示しない」「更新失敗時は入力内容を退避して再ログインへ案内する」といった振る舞いをテストできます。フロントエンドの状態は利用者体験を整えるためのものであり、信頼できる認可根拠ではありません。API側では要求ごとにトークンと権限を検証します。
No.057:ログアウト処理を実装する
実務での意味
共有タブレットではログアウトが特に重要です。ブラウザ上の利用者情報を消すだけでは、盗まれた更新トークンやサーバー側セッションが有効なまま残る場合があります。明示ログアウト、無操作タイムアウト、端末紛失、パスワード変更を別の失効イベントとして設計します。
分析・モデル化の考え方
セッションの危険度を、共有端末、長時間経過、失効未反映の組み合わせで評価します。サーバー管理セッションなら即時失効しやすく、自己完結型JWTでは短い有効期限、更新トークン失効、jti拒否リストなどを組み合わせます。
Pythonで確認する
sessions["shared_device"] = sessions["device"].eq("共有タブレット")
sessions["stale"] = sessions["age_min"] > 240
sessions["risk_score"] = 2 * sessions["shared_device"].astype(int) + 2 * sessions["stale"].astype(int) + 3 * (~sessions["revoked"]).astype(int)
logout_policy = pd.DataFrame({
"施策": ["端末側のみ削除", "更新トークンを失効", "全セッションを失効"],
"端末紛失後に残り得るセッション数": [int((~sessions["revoked"]).sum()), int((~sessions["revoked"] & ~sessions["stale"]).sum()), 0],
})
display(sessions.sort_values("risk_score", ascending=False).head(8)[["session_id", "device", "age_min", "revoked", "risk_score"]])
display(logout_policy)
| session_id | device | age_min | revoked | risk_score | |
|---|---|---|---|---|---|
| 54 | S0055 | 共有タブレット | 457 | False | 7 |
| 44 | S0045 | 共有タブレット | 379 | False | 7 |
| 72 | S0073 | 共有タブレット | 477 | False | 7 |
| 138 | S0139 | 共有タブレット | 435 | False | 7 |
| 67 | S0068 | 共有タブレット | 616 | False | 7 |
| 66 | S0067 | 共有タブレット | 353 | False | 7 |
| 140 | S0141 | 共有タブレット | 603 | False | 7 |
| 64 | S0065 | 共有タブレット | 312 | False | 7 |
| 施策 | 端末紛失後に残り得るセッション数 | |
|---|---|---|
| 0 | 端末側のみ削除 | 165 |
| 1 | 更新トークンを失効 | 49 |
| 2 | 全セッションを失効 | 0 |
結果の読み取り
端末側だけの削除では、別にコピーされた認証情報を無効化できません。特に共有端末では、短い無操作期限と明示ログアウトを併用し、交替時に利用者名を明確に表示します。ただし作業中の一律タイムアウトが安全作業を妨げないよう、工程の区切り、再認証の速さ、入力内容の保持も現場で検証します。
No.058:管理者と一般ユーザーを分ける
実務での意味
管理者はユーザーや権限を変更できるため、通常業務用アカウントと同じ扱いにしません。作業者、班長、品質保証、管理者という職務に操作を割り当て、管理者権限の日常利用を減らします。緊急時の一時昇格には承認と期限を持たせます。
分析・モデル化の考え方
RBAC(Role-Based Access Control)では、利用者へ個別権限を大量に付けるのではなく、職務ロールへ権限をまとめます。さらに所属ライン、設備、工場などの属性で範囲を絞ります。権限表は業務責任者と情報システムが共同でレビューできる形式にします。
Pythonで確認する
permission_matrix = pd.DataFrame({
"操作": ["作業指示閲覧", "実績登録", "実績承認", "品質判定確定", "監査ログ閲覧", "ユーザー管理"],
"作業者": [1, 1, 0, 0, 0, 0],
"班長": [1, 1, 1, 0, 0, 0],
"品質保証": [1, 0, 0, 1, 0, 0],
"管理者": [1, 0, 0, 0, 1, 1],
}).set_index("操作")
display(permission_matrix.replace({1: "許可", 0: "拒否"}))
fig, ax = plt.subplots(figsize=(7, 4.5))
im = ax.imshow(permission_matrix.T, cmap="Blues", vmin=0, vmax=1, aspect="auto")
ax.set_title("ロール別の操作権限マトリクス")
ax.set_xlabel("業務操作")
ax.set_ylabel("ロール")
ax.set_xticks(range(len(permission_matrix.index)), permission_matrix.index, rotation=30, ha="right")
ax.set_yticks(range(len(permission_matrix.columns)), permission_matrix.columns)
ax.grid(False)
plt.tight_layout()
plt.show()
| 作業者 | 班長 | 品質保証 | 管理者 | |
|---|---|---|---|---|
| 操作 | ||||
| 作業指示閲覧 | 許可 | 許可 | 許可 | 許可 |
| 実績登録 | 許可 | 許可 | 拒否 | 拒否 |
| 実績承認 | 拒否 | 許可 | 拒否 | 拒否 |
| 品質判定確定 | 拒否 | 拒否 | 許可 | 拒否 |
| 監査ログ閲覧 | 拒否 | 拒否 | 拒否 | 許可 |
| ユーザー管理 | 拒否 | 拒否 | 拒否 | 許可 |
結果の読み取り
管理者に全業務操作を与えず、管理機能へ限定した設計例です。「管理者だから何でもできる」状態を避けると、誤操作とアカウント侵害時の影響を抑えられます。実務では職務分掌に応じて、申請者と承認者を同一人物にしない相互牽制、異動日の自動反映、四半期ごとの権限棚卸しを追加します。
No.059:権限によって表示する画面を切り替える
実務での意味
権限に合わないメニューを隠すと、利用者は必要な作業へ集中できます。ただし、画面を隠すことはセキュリティ上の認可ではありません。URL直打ちや開発者ツールからAPIを呼ばれても操作できないよう、サーバー側の判定を必須にします。
分析・モデル化の考え方
各ルートに必要権限を宣言し、ログイン利用者の権限集合との包含関係で表示可否を決めます。403 Forbiddenでは、単なる白画面にせず、権限不足であること、申請先、業務へ戻る導線を表示します。
Pythonで確認する
route_rules = pd.DataFrame({
"route": ["/work-orders", "/results/new", "/approvals", "/quality", "/admin/audit", "/admin/users"],
"menu_label": ["作業指示", "実績入力", "実績承認", "品質判定", "監査ログ", "ユーザー管理"],
"allowed_roles": [roles, roles, ["班長"], ["品質保証"], ["管理者"], ["管理者"]],
})
visibility = pd.DataFrame(index=route_rules["menu_label"], columns=roles)
for _, row in route_rules.iterrows():
for role in roles:
visibility.loc[row["menu_label"], role] = "表示" if role in row["allowed_roles"] else "非表示"
display(visibility)
| 作業者 | 班長 | 品質保証 | 管理者 | |
|---|---|---|---|---|
| menu_label | ||||
| 作業指示 | 表示 | 表示 | 表示 | 表示 |
| 実績入力 | 表示 | 表示 | 表示 | 表示 |
| 実績承認 | 非表示 | 表示 | 非表示 | 非表示 |
| 品質判定 | 非表示 | 非表示 | 表示 | 非表示 |
| 監査ログ | 非表示 | 非表示 | 非表示 | 表示 |
| ユーザー管理 | 非表示 | 非表示 | 非表示 | 表示 |
結果の読み取り
ロール別にメニューを整理すると、作業者へ管理メニューを見せず、品質保証へ必要な品質判定だけを提示できます。表示制御とAPI認可は同じ権限定義から生成すると不一致を減らせますが、最終判断は必ずAPI側に置きます。権限変更後に古いメニューが残らないよう、再取得やセッション更新のタイミングも決めます。
No.060:認証付きAPIを保護する
実務での意味
保護対象APIは、トークンが存在するだけで許可してはいけません。署名、有効期限、発行者、利用目的、アカウント状態、ロール、対象ラインを一貫した順序で確認します。拒否も監査対象ですが、トークンそのものやパスワードをログへ残してはいけません。
分析・モデル化の考え方
多層防御として、①認証情報の形式、②暗号学的検証、③claims、④アカウント状態、⑤ロール、⑥データ範囲を判定します。判定順ごとの拒否件数を測ると、攻撃兆候と設定不備を区別しやすくなります。HTTPでは未認証を401、認証済みだが権限不足を403として扱うのが基本です。
Pythonで確認する
api_access["decision_reason"] = np.select(
[
~api_access["token_present"],
~api_access["signature_valid"],
api_access["token_expired"],
~api_access["account_status"].eq("active"),
~api_access["role_allowed"],
~api_access["scope_allowed"],
],
["tokenなし", "署名不正", "期限切れ", "アカウント無効", "ロール不足", "担当範囲外"],
default="許可",
)
decision_summary = api_access["decision_reason"].value_counts().rename_axis("判定").reset_index(name="件数")
decision_summary["構成比_%"] = (decision_summary["件数"] / len(api_access) * 100).round(1)
display(decision_summary)
fig, ax = plt.subplots(figsize=(8.5, 4.2))
colors = ["#2A9D8F" if x == "許可" else "#E76F51" for x in decision_summary["判定"]]
ax.bar(decision_summary["判定"], decision_summary["件数"], color=colors)
ax.set_title("認証付きAPIの最終判定と拒否理由")
ax.set_xlabel("判定理由")
ax.set_ylabel("要求件数")
ax.grid(axis="y", alpha=0.3)
plt.xticks(rotation=20)
plt.tight_layout()
plt.show()
| 判定 | 件数 | 構成比_% | |
|---|---|---|---|
| 0 | 許可 | 677 | 28.2 |
| 1 | 担当範囲外 | 648 | 27.0 |
| 2 | ロール不足 | 578 | 24.1 |
| 3 | アカウント無効 | 327 | 13.6 |
| 4 | 期限切れ | 92 | 3.8 |
| 5 | tokenなし | 59 | 2.5 |
| 6 | 署名不正 | 19 | 0.8 |
結果の読み取り
拒否理由別の件数により、期限設定が短すぎるのか、権限表が業務に合っていないのか、署名不正が増えているのかを切り分けられます。ただし、拒否率が低ければ安全とは限りません。保護漏れのエンドポイントがないかを自動テストし、特権操作には再認証、承認、改ざん耐性のある監査ログも検討します。
対象ノックを通して見える実務上の示唆
-
認証成功は操作許可を意味しない
本人確認の後に、ロールと担当範囲を要求ごとに判定する必要があります。 -
フロントエンドは導線、APIは強制点
メニューの表示制御は使いやすさに有効ですが、セキュリティ境界はAPI側に置きます。 -
共有端末ではセッション設計が業務品質へ直結する
利用者名の明示、無操作期限、交替時ログアウト、素早い再認証を一体で設計します。 -
権限は人ではなく職務へ割り当て、定期的に棚卸しする
RBACと所属範囲を組み合わせ、異動・退職・応援勤務の期限を反映します。 -
拒否を異常終了ではなく管理指標として扱う
認証失敗、ロール不足、担当外、期限切れを分けることで、攻撃兆候と業務設計の不整合を発見できます。
実務導入する場合に必要なこと
1. 業務とIDライフサイクルの整理
入社、異動、応援勤務、休職、退職時に、誰がどのシステムへいつまでアクセスできるかを定義します。人事・組織マスタとの連携、申請・承認、緊急アクセス、定期棚卸しの責任者を決めます。
2. 脅威モデルと認証方式の選定
共有端末、工場ネットワーク、社外アクセス、扱う品質情報の重要度を基に、SSO、MFA、パスキー、端末証明書、セッション期限を選びます。方式名だけで決めず、端末紛失、フィッシング、内部不正、通信断を想定します。
3. 一元化した認可と自動テスト
画面、API、バッチで権限定義がばらばらにならないよう、共通ポリシーと監査可能な変更手続きを設けます。未認証、期限切れ、ロール不足、担当外、権限変更直後をテストケースに含めます。
4. 監視とインシデント対応
連続失敗、通常と異なる端末・時間帯、特権操作、拒否急増を監視します。ログへ秘密情報を残さず、保存期間、閲覧権限、時刻同期、通報・封じ込め手順を定めます。
5. 現場でのユーザビリティ検証
手袋、騒音、端末台数、交替時間、通信品質を含む実環境で、ログイン完了時間、再認証回数、作業中断、問い合わせ件数を測定し、安全性とのバランスを更新します。
まとめ
No.051〜No.060では、認証と認可の分離から、ログイン、パスワード、JWT、フロントエンド状態、ログアウト、RBAC、画面切替、API保護までを、工場の作業実績・品質記録システムとして確認しました。
重要なのは、JWTやログイン画面を導入すること自体ではなく、本人・職務・対象範囲・有効時間を一貫して検証し、その判断を監査できることです。架空ログの集計で見たように、許可件数だけでなく、拒否理由、端末別完了時間、残存セッションを追うと、セキュリティと業務継続の両方を改善する材料が得られます。
実導入では、既存のID基盤、職務分掌、共有端末運用、法令・取引先要求に合わせて設計し、定期的な権限棚卸しとテストを継続してください。
法人向けのご相談
数理工房では、製造業の業務整理、データ分析、AI・数理モデル開発に加えて、それらを安全に現場運用するためのWebシステム・認証認可基盤の設計開発をご支援しています。
- 工場の共有端末を前提としたログイン・セッションを設計したい
- 部門・工場・ライン単位の権限を整理したい
- 分析ダッシュボードやAI APIへ認証・認可を組み込みたい
- 既存システムの権限漏れや監査ログを点検したい
- PoCから本番運用へ移るためのセキュリティ要件を整理したい
📩 お問い合わせ: surikobo.co.jp/contact
まずはお気軽にご相談ください。