対象読者
- ローカルサーバをそのまま LAN 内の他の端末に共有したいが、誰でも入れる状態は避けたい人
- Spring Boot の
HandlerInterceptor/HandshakeInterceptorで認可を組みたい人 - 「サーバ側で書き込みを拒否する」閲覧専用ロールの実装例が欲しい人
対象は個人開発中のチーム用ワークスペース Kurari の LAN 共有機能です。ホワイトボード・ドキュメント・チャット・AI出力を1画面に統合するツールで、ローカルで起動した backend を同じ LAN 上の他の端末(スマホ・別PC)から見られるようにする機能に、オーナー承認制のアクセス制御を実装しました。
動作環境
- backend: Kotlin 2.1.21 / Spring Boot 3.5.16(Java 21 toolchain)
- frontend: Vite 8.1.1 / React 19.2.7
- dev 環境は Vite の dev server が
/api/wsを backend の:8080へxfwd: trueでプロキシする構成(本記事の IP 判定はこのプロキシ構成が前提です)
要件
Kurari はオーナー(サーバを起動した本人)が普段は localhost で使うツールです。ミーティングなどでチームメンバーに画面を共有し、同じ LAN から一時的に参加してもらいたいという場面があります。ここで満たしたい要件は次の3つでした。
- オーナーが招待リンクを発行し、同じ LAN 内の端末だけがそのリンクで参加リクエストを送れる
- 参加リクエストはオーナーが承認するまで何もできない(自動承認しない)
- 「見るだけで編集はさせたくない」相手には閲覧専用(viewer)ロールで承認できる
これを access パッケージ(backend/src/main/kotlin/app/kurari/access/)にまとめて実装しています。以下、判定ロジックの核心である IP 判定から順に説明します。
実効IPをXFFの末尾要素で判定する
オーナーかどうかの判定は「実効クライアント IP が loopback かどうか」です。ここで単純に request.remoteAddr だけを見ると、Vite の dev プロキシ経由のリクエストはすべて 127.0.0.1 から backend に届くため、LAN 端末からのアクセスとオーナー自身のアクセスを区別できません。
backend/src/main/kotlin/app/kurari/access/ClientIp.kt(19〜23行目)が実効 IP を決めるロジックです。
fun effective(peer: String, xff: String?): String {
if (!isLoopback(peer)) return peer
val last = xff?.split(",")?.lastOrNull()?.trim()
return if (last.isNullOrEmpty()) peer else last
}
ポイントは X-Forwarded-For の末尾を見ていることです。ファイル冒頭のコメント(6〜11行目)に判定の全体像がまとまっています。
/**
* 実効クライアント IP の判定。Vite dev プロキシ(xfwd: true)前提のルール:
* 1. 直接のピアが loopback でない → ピア IP(XFF は信用しない。LAN からの :8080 直叩き)
* 2. ピアが loopback で XFF なし → loopback(= オーナー。agent / E2E / curl の直叩き)
* 3. ピアが loopback で XFF あり(= Vite 経由)→ XFF の「最後の要素」を実効 IP とする。
* http-proxy は既存 XFF の末尾に実ピア IP を追記するため、末尾ルールなら偽装できない
* (先頭要素はクライアントが自由に付けられるので絶対に使わないこと)
*/
X-Forwarded-For はクライアントがヘッダとして送ってくる値なので、先頭要素は誰でも好きな値を書けます。一方で Node の http-proxy(Vite が内部で使うプロキシ)は、受け取った XFF の末尾に実際に接続してきたピアの IP を追記する実装になっています。なので「末尾」だけは中継のたびに信頼できる実プロキシが付け足した値であり、クライアントが直接偽装することはできません。これが「先頭ではなく末尾を見る」理由です。
もう一つ注意が必要なのは 2 番目のケースです。localhost への直叩き(agent プロセス、Playwright の E2E、curl http://localhost:8080/...)は Vite プロキシを経由しないため X-Forwarded-For が付きません。この場合は effective() が peer(= loopback)をそのまま返すので、XFF なしの loopback アクセスはオーナー扱いになります。これは意図した挙動です。backend を直接叩く開発ツールや E2E テストのために「認可のバイパス」を別途用意する必要がなく、isOwner() の判定だけで済みます。
isOwner は AccessController.kt の requireOwner(116〜120行目)など、招待発行・承認・拒否といったオーナー専用 API のガードにそのまま使われています。
private fun requireOwner(request: HttpServletRequest) {
if (!ClientIp.isOwner(request)) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "オーナーのみ実行できます")
}
}
全APIを既定でゲートし、isPreAuth だけ許可リストにする
Kurari では「新しく API を追加したら、明示的に許可しない限り認可の対象になる」という設計にしています。AccessInterceptor を /api/** 全体に登録しているのがそれです(AccessInterceptor.kt 67〜72行目)。
@Configuration
class AccessInterceptorConfig(private val accessInterceptor: AccessInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(accessInterceptor).addPathPatterns("/api/**")
}
}
preHandle(26〜50行目)の流れはシンプルです。
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
if (request.method == "OPTIONS") return true // CORS preflight
if (isPreAuth(request)) return true
if (ClientIp.isOwner(request)) return true
val member = registry.memberOf(bearerToken(request))
if (member != null) {
if (member.role == "viewer" && request.method !in setOf("GET", "OPTIONS")) {
response.status = HttpServletResponse.SC_FORBIDDEN
response.contentType = "application/json;charset=UTF-8"
response.writer.write(
"""{"error":{"code":"READ_ONLY","message":"閲覧のみのため変更できません"}}""",
)
return false
}
return true
}
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = "application/json;charset=UTF-8"
response.writer.write("""{"error":{"code":"UNAUTHORIZED","message":"アクセスが承認されていません"}}""")
return false
}
未承認かつオーナーでもない相手は 401 で弾かれます。これがデフォルトの挙動なので、認可の判断が必要な新規 API を追加するときに実装漏れが起きにくいというのが狙いです。
例外は isPreAuth(52〜64行目)で明示的にホワイトリスト化した3種類だけです。
private fun isPreAuth(request: HttpServletRequest): Boolean {
val path = request.requestURI
return when (request.method) {
"POST" -> path == "/api/access/join"
"GET" ->
path == "/api/access/me" ||
path.startsWith("/api/access/join/") ||
// ファイルはファイル名(UUID)を知っている人だけが読めるケーパビリティURL扱い
path.startsWith("/api/files/")
else -> false
}
}
-
POST /api/access/join: 参加リクエストの送信そのもの。承認前なので当然ゲートの外 -
GET /api/access/me,GET /api/access/join/{id}: 自分のロール確認と、承認待ちのポーリング -
GET /api/files/**:<img src>から直接読み込まれるファイル。UUID を知っている人だけが読める「ケーパビリティ URL」として扱っています
Spring MVC の excludePathPatterns を使わず自前で method + path を判定しているのは、excludePathPatterns がパスだけでメソッドを区別できないためです。POST /api/access/join は許可したいが GET /api/access/join(存在しないパスですが)まで一律に許可したくない、という粒度をコード側で持たせています。
REST は Bearer、WS はクエリパラメータ
REST と WebSocket でトークンの渡し方を変えています。REST は Authorization: Bearer ヘッダ、WebSocket は ?token= のクエリパラメータです。
frontend/src/lib/access-token.ts がトークンの保管ハブです(1〜5行目のコメントに役割が書かれています)。
/**
* アクセストークン(LAN 参加者用)の保管と 401 通知のハブ。
* api.ts / ws.ts はこのモジュールだけを参照する(store への循環 import を避ける)。
* オーナー(localhost)はトークン不要なので常に null のまま。
*/
REST 側は frontend/src/lib/api.ts(5〜9行目)で authHeaders() が毎リクエストに Authorization を付けます。
/** LAN 参加者はアクセストークンを常時付与する(オーナーは null なので付かない) */
function authHeaders(): Record<string, string> {
const token = getAccessToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
WebSocket 側は frontend/src/lib/ws.ts(25〜28行目)でクエリパラメータに載せます。
const token = getAccessToken()
ws = new WebSocket(
`${proto}://${location.host}/ws${token ? `?token=${encodeURIComponent(token)}` : ''}`,
)
WebSocket の握手(handshake)リクエストは、ブラウザの WebSocket API 自体には任意ヘッダを追加する手段がありません。Authorization ヘッダを付けたくても付けられないので、トークンをクエリパラメータで渡す方式にしています。backend 側の受け口は AccessHandshakeInterceptor.kt(30〜31行目)です。
val token = UriComponentsBuilder.fromUri(request.uri).build().queryParams.getFirst("token")
val member = registry.memberOf(token)
beforeHandshake 全体(18〜38行目)は REST 側の AccessInterceptor とほぼ同じ判定順です。実効 IP が loopback ならオーナーとして通し、そうでなければトークンを検証します。
override fun beforeHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>,
): Boolean {
val peer = request.remoteAddress?.address?.hostAddress ?: ""
val xff = request.headers.getFirst("X-Forwarded-For")
if (ClientIp.isLoopback(ClientIp.effective(peer, xff))) {
attributes["accessRole"] = "owner"
return true
}
val token = UriComponentsBuilder.fromUri(request.uri).build().queryParams.getFirst("token")
val member = registry.memberOf(token)
if (member != null) {
attributes["accessRole"] = member.role
return true
}
response.setStatusCode(HttpStatus.UNAUTHORIZED)
return false
}
握手時に確定した accessRole は WebSocketSession.attributes に積んでおき、以後のメッセージ処理で毎回読み直します。次の viewer の話に直結します。
viewer の書き込みはサーバ側で拒否する
閲覧専用(viewer)ロールは、REST も WebSocket もサーバ側で書き込みを拒否します。フロントの UI を非表示にするだけでは、DevTools から直接 fetch を叩けば書き込めてしまうので、これは UI の話とは別に必要な実装です。
REST 側は前述の AccessInterceptor.preHandle で見た通りです。member.role == "viewer" かつメソッドが GET/OPTIONS 以外なら、ハンドラに到達する前に 403 で止めます。
if (member.role == "viewer" && request.method !in setOf("GET", "OPTIONS")) {
response.status = HttpServletResponse.SC_FORBIDDEN
response.contentType = "application/json;charset=UTF-8"
response.writer.write(
"""{"error":{"code":"READ_ONLY","message":"閲覧のみのため変更できません"}}""",
)
return false
}
WebSocket 側は EventBroadcaster.handleTextMessage(backend/src/main/kotlin/app/kurari/ws/EventBroadcaster.kt 60〜64行目)で、セッションに積んだ accessRole を見て弾いています。
val role = session.attributes["accessRole"] as? String ?: "owner"
if (
role == "viewer" &&
type !in setOf("presence.join", "presence.update", "reaction.ping")
) return
viewer に許可しているのは在席表示(presence.join / presence.update)と一時的な絵文字リアクション(reaction.ping)だけです。それ以外のメッセージ type(timer.start など)は黙って return するだけで、エラーも返しません。REST が 403 を明示的に返すのに対して WS 側が黙殺なのは、ボード操作のような WS 経由の書き込みリクエストがそもそも存在しないためです(Kurari のデータ変更は REST 経由、WS はプレゼンスやシグナリングのみ)。
フロント側の UI 制御は frontend/src/lib/use-can-edit.ts の1行にまとまっています。
export const useCanEdit = () =>
useAccessStore((state) => state.role === 'owner' || state.role === 'member')
この useCanEdit() を BoardMode.tsx(1136行目)などで参照し、false のときはツールバーごと出し分けています。
{canEdit ? <BoardToolbar ... /> : (
<div className="absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1 text-xs text-neutral-500 shadow-sm">
<span>閲覧のみ</span>
<button type="button" title="絵文字リアクション" ...>👍</button>
</div>
)}
viewer で承認された画面を実際に開くと、付箋を追加する道具や描画ツールが並ぶ通常のツールバーは表示されず、代わりに「閲覧のみ」というラベルと絵文字リアクションボタンだけが載った小さいピルが画面上部中央に浮きます。右パネルのコメント欄も「閲覧のみのため書き込めません」という文言に置き換わります。ここが useCanEdit() の分岐です。ただしこれはあくまで UX で、実際に書き込みを止めているのは前述のサーバ側の判定です。この二重構成(サーバが拒否・クライアントは UI を出さない)を、コメント(CLAUDE.md 61行目)では次のように書いています。
承認ロールは editor / viewer。viewer は GET/OPTIONS と presence・一時リアクションのみ許可し、REST/WS の書き込みはサーバ側で拒否する(UI非表示だけに依存しない)。
承認バナー側は AccessRequestBanner.tsx で editor 承認・viewer 承認・拒否の3ボタンを出しています(32〜52行目)。
<button data-testid="access-approve" onClick={() => void approve(p.requestId)}>
承認
</button>
<button data-testid="access-approve-viewer" onClick={() => void approve(p.requestId, 'viewer')}>
閲覧のみで承認
</button>
<button data-testid="access-deny" onClick={() => void deny(p.requestId)}>
拒否
</button>
オーナー側の画面には「〇〇さんが参加をリクエスト」という文言とともに「承認」「閲覧のみで承認」「拒否」の3ボタンが並ぶカードが右上に積み上がります。「閲覧のみで承認」を押すと approve(requestId, 'viewer') が呼ばれ、AccessController.approve(AccessController.kt 90〜105行目)の role に "viewer" が渡ります。
承認状態はメモリで持つ
AccessRegistry.kt(1〜23行目)のクラスコメントにある通り、招待トークン・参加リクエスト・承認済みメンバーはすべてメモリ(ConcurrentHashMap / AtomicReference)で管理していて、DB には永続化していません。
/**
* LAN 共有のアクセス制御をメモリで管理する(presence と同じ流儀・永続化しない)。
* - invite: オーナーが発行する招待トークン。最新1本のみ有効(再発行が実質の失効手段)
* - requests: 参加リクエスト。承認/拒否の結果はポーリングで参加者に返す
* - tokens: 承認で発行するアクセストークン。失効は backend 再起動のみ
*/
これには明確なトレードオフがあります。backend を再起動すると tokens マップが空になるため、それまで承認していたメンバー全員のアクセストークンが無効になり、全員が再度参加リクエストからやり直す必要があります。フロント側はこれを想定していて、REST が 401 を返すと api.ts(24行目)で notifyUnauthorized() を呼び、アクセスゲート画面へ戻す実装になっています。
if (res.status === 401) notifyUnauthorized() // backend 再起動などでトークン失効 → ゲートへ
Kurari の LAN 共有は「ミーティング中だけ一時的に使う」用途を想定しているため、この割り切りは妥当だと判断しました。逆に「参加者を長期間覚えておく」用途には向かない設計です。もし長時間セッションを維持したいなら、トークンを DB か Redis に永続化する必要があります。
招待トークンにも寿命があります。issueInvite()(AccessRegistry.kt 52〜57行目)で発行時刻から invite-ttl-seconds 秒後の期限を設定します。
fun issueInvite(): Pair<String, Instant> {
val token = randomToken()
val expiresAt = Instant.now().plusSeconds(inviteTtlSeconds)
invite.set(Invite(token, expiresAt))
return token to expiresAt
}
設定値は application.yml で invite-ttl-seconds: 3600、つまり1時間です。
kurari:
access:
# 招待トークンの有効期限(最新1本のみ有効。再発行で失効)
invite-ttl-seconds: 3600
# 未処理の参加リクエストを破棄するまでの秒数
pending-ttl-seconds: 900
# 承認/拒否済みリクエストを保持する秒数(参加者のポーリングが結果を読む猶予)
resolved-ttl-seconds: 600
# 承認待ちの同時上限(超過は 429)
max-pending: 20
invite は AtomicReference<Invite?> 1個だけを保持しているので、招待は常に「最新の1本だけ」が有効です。オーナーがもう一度「招待」ボタンを押して新しいリンクを発行すると、古いリンクはその時点で isInviteValid() が false を返すようになり、実質的に失効します。専用の失効 API は用意しておらず、再発行そのものが失効の手段になっている設計です。
参加リクエストにも pending-ttl-seconds(15分)と resolved-ttl-seconds(10分)があり、AccessRegistry.evictStale()(118〜128行目)が1分ごとに掃除しています。未承認のまま放置されたリクエストは15分で消え、承認・拒否済みのリクエストも参加者側のポーリングが結果を受け取るのに十分な10分の猶予を置いてから消えます。承認待ちの同時件数にも max-pending: 20 の上限があり、超過時は AccessController.join(AccessController.kt 40〜42行目)が 429 を返します。
E2E での検証
これらの挙動は frontend/e2e/smoke.mjs の一連のテストで確認しています。招待リンクの発行から承認までの流れ(696〜726行目)では、2人目のブラウザコンテキストで招待リンクを開いて参加リクエストを送ります。オーナー側は承認バナーの「承認」ボタンを押します。その後、承認されたメンバーのトークンでバックアップ API を叩くと 403 になることを確認しています(バックアップはオーナー専用 API のため)。
拒否のケース(939〜949行目)は3人目のコンテキストで参加リクエストを送り、オーナーが「拒否」ボタンを押すと参加者側にそれが伝わることを確認します。未承認クライアントの API 遮断(951〜954行目)は、トークンなしで API を叩くと 401 になることの確認です。
viewer 関連は 957〜1039行目の2項目です。「閲覧のみで承認」ボタンで4人目を承認したあと、付箋を追加ボタンが画面上に存在しないことを確認します。それでいて他のメンバーが行った編集はリアルタイムに反映されること、絵文字リアクションは使えることも確認しています。続けて、viewer のトークンで REST の書き込み API を直接叩くと 403 かつ error.code が READ_ONLY になること、WS 経由の書き込みメッセージも黙殺されることを確認しています。UI を経由しない直接アクセスでも viewer が書き込めないことを保証する回帰テストです。