Old-School Front-End / 2026 ・ No.11
いにしえのフロントエンド入門
第11回 ログインと画面の切り替え ── 「あなたは誰?」を、どの画面でも確かめる
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第11回はログインと画面の切り替えです。
これまでヘッダーの「新井 太郎(申請者)」は、HTML に書いてあるだけの飾りでした。
今回はログイン画面を作り、申請一覧・申請フォーム・承認の画面のどれを開いても、最初に「あなたは誰?」をサーバーに確かめます。
そして、画面で隠すことと、サーバーで断ることの違いを、実際に試して確かめます。
今回のゴール
新井前回、申請番号が出ました! でも先輩、あの申請はだれが出したことになってるんでしょう。
古井今はだれでもない。ヘッダーの「新井 太郎」は、HTML に書いた文字のままだ。
新井じゃあ、私が「古井 一郎」と書きかえたら、先輩の代わりに承認できちゃう……?
古井そうならないための仕組みを、今日作る。
いまここ:第11回
- ログイン画面 ── メールアドレスとパスワードで名乗る。間違えたら、どちらが違うかは言わずに知らせる
- どの画面でも確かめる ── 申請一覧・申請フォーム・承認の画面は、開いたらまずサーバーに「あなたは誰?」と聞く。ログインしていなければ、ログイン画面へ。済んだら元の画面へ戻す
- 人によって変える ── 申請一覧には自分の申請だけ。「承認」のメニューは承認者にだけ見せ、承認のデータはサーバーが承認者にしか渡さない
- ログアウト ── 合言葉を捨てて、ログイン画面へ
相手は前回の練習用サーバーに、ログインと一覧の機能を足したものです(中身は本題ではありません)。アカウントとパスワード([email protected] / demo-arai など)は、このデモのための架空のものです。
画面の地図
ログイン画面と、3つの画面
画面の切り替えは、いにしえのやり方で行きます。画面ごとに HTML ファイルを分け、メニューはただの <a href> のリンク。押せばブラウザが次のページを読み込みます。いまいる画面のメニューには aria-current="page" を付けて、読み上げソフトにも「ここ」と伝えます。
ここで大事なのは、画面(HTML ファイル)そのものは、だれでも取れることです。ログインしていないブラウザで試しました。
# ログインしていないブラウザから(Chromium で実測) 取りに行ったもの 状態コード /approve.html(画面の HTML) 200 誰でも取れる /api/me 401 ログインしていない /api/expenses(自分の申請) 401 /api/approvals(承認待ち) 401
承認の画面の HTML は 200 で手に入ります。中身の表は空っぽで、データは /api/… から取ってきて埋めるからです。守るべきものは画面ではなく、サーバーが返すデータ。この回の考え方は、すべてここから始まります。
ログインのしくみ:合言葉
パスワードは最初の1回だけ。あとは合言葉で名乗る
ログインに成功すると、サーバーは返事の見出しに Set-Cookie を付けて、合言葉(セッション ID)をブラウザに預けます。ブラウザはこれをクッキーとしてしまっておき、同じサイトへの頼みには自動で付けて送ります。サーバーは手元の表で「この合言葉は新井さん」と引き当てます。
| クッキーの印 | 意味 |
|---|---|
| HttpOnly | JavaScript から読めない(八で試す) |
| SameSite=Lax | よそのサイトから送られる POST などには付けない |
| Path=/ | このサイトのどのページへの頼みにも付ける |
| Max-Age=28800 | 8時間で捨てる(その後はログインし直し) |
本番の https のサイトでは、さらに Secure(暗号化した通信でしか送らない)も付けます。練習用サーバーは http なので付けていません。
ログイン画面を作る
<form id="login-form">
<div>
<label for="email">メールアドレス</label>
<input type="email" id="email" name="email" autocomplete="username" required>
</div>
<div>
<label for="password">パスワード</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<p id="login-error" role="alert" hidden></p>
<button type="submit">ログイン</button>
</form>
- type="password" ── 打った文字を●で隠す
- autocomplete ── username と current-password を付けると、ブラウザやパスワード管理ソフトが「ここがログインの欄だ」と分かり、保存や自動入力を手伝える
- 入力チェックはブラウザに任せる ── 欄が2つだけなので、第9回の required の吹き出しで十分。凝るのは申請フォームのような長いフォームだけでいい
// 経費精算システム:ログイン画面(第11回)
// ログインできたら、元の画面(next)へ
const loginForm = document.querySelector('#login-form');
const loginError = document.querySelector('#login-error');
// ログインボタンが押されたとき
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
loginError.hidden = true;
const button = event.submitter;
button.disabled = true;
try {
const res = await fetch('/api/login', { method: 'POST', body: new FormData(loginForm) });
if (res.status === 401) { // ID とパスワードのどちらが違うかは、あえて言わない
loginError.textContent = 'メールアドレスかパスワードが違います。';
loginError.hidden = false;
loginForm.elements.password.select(); // パスワードを選んだ状態に(打ち直しやすく)
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const next = new URLSearchParams(location.search).get('next');
location.replace(safeNext(next, location.origin)); // replace:「戻る」でログイン画面に戻らないように
} catch (error) {
loginError.textContent = 'ログインできませんでした。通信の状態を確かめて、もう一度お試しください。';
loginError.hidden = false;
} finally {
button.disabled = false;
}
});
間違えたときのメッセージは「メールアドレスかパスワードが違います」。どちらが違うかは、あえて言いません。「パスワードが違います」と言うと、「このメールアドレスは登録されている」と、よその人に教えてしまうからです。実際に、パスワード違いとメールアドレス違いで、同じ文言になることを確かめました(九の表)。
戻り先の罠
申請フォームを開こうとしてログイン画面に回されたら、ログインのあとは申請フォームに戻ってほしいですね。そこで、ログイン画面の URL に戻り先を付けておきます。
/login.html?next=%2Fform.html
ところが、この next はだれでも書きかえられます。?next=https://evil.example/ と書いたリンクを送りつけられたら、ログインした直後に、本物そっくりの偽サイトへ飛ばされてしまいます(オープンリダイレクトと呼ばれる罠です)。「/ で始まっていれば同じサイト」と考えたくなりますが、試すと……
URL の部品と、同じサイトかどうか
# safeNext の結果(Chromium で実測。いまのページは http://127.0.0.1:3000) next の値 URL として読むと 戻る先 /form.html (同じサイト)/form.html /form.html /approve.html?tab=1 (同じサイト)/approve.html?tab=1 /approve.html?tab=1 (空) (同じサイト)/index.html /index.html https://evil.example/ https://evil.example/ /index.html ← よそのサイト //evil.example/x http://evil.example/x /index.html ← よそのサイト /\evil.example/x http://evil.example/x /index.html ← よそのサイト javascript:alert(1) javascript:alert(1) /index.html ← よそのサイト
//evil.example/x も、/ のあとに \ を書いた /\evil.example/x も、/ で始まっているのに、ブラウザはよそのサイトとして読みました。だから safeNext は文字の形では判断せず、new URL() でブラウザと同じように読んでから、origin(どのサイトか)が同じかを比べます。違えば申請一覧へ。
もう一つ。ログインのあとの移動は location.replace() にしています。
# ログインのあと、どう移るか(Chromium で実測) 移り方 履歴の数 「戻る」を押すと location.assign() 3 ログイン画面 location.replace() 2 ログイン画面より前(about:blank)
assign(ふつうのリンクと同じ)だと履歴にログイン画面が残り、「戻る」でまたログイン画面が出ます。replace はいまの履歴を置きかえるので、ログイン画面は履歴に残りません。
どの画面でも「あなたは誰?」
どの画面にも共通の仕事は、js/auth.js にまとめました。申請一覧・申請フォーム・承認の画面は、最初に requireLogin() を呼びます。
// 経費精算システム:ログインの確認と、ヘッダーの表示(第11回)
// どの画面でも最初に読み込む。ログインしていなければ、ログイン画面へ
const ROLE = { applicant: '申請者', approver: '承認者' };
/** 戻り先として安全か確かめる。同じサイトの中だけを許す */
function safeNext(next, origin) {
try {
const url = new URL(next || '/index.html', origin); // next が無ければ申請一覧へ
if (url.origin !== origin) return '/index.html'; // よそのサイトは許さない
return url.pathname + url.search;
} catch {
return '/index.html';
}
}
/** ログインしている人を聞く。していなければ、ログイン画面へ送る */
async function requireLogin() {
const res = await fetch('/api/me');
if (res.status === 401) { // 401 = ログインしていない
const next = location.pathname + location.search;
location.replace(`/login.html?next=${encodeURIComponent(next)}`);
return new Promise(() => {}); // 画面が切り替わるまで、続きを動かさない(返事の来ない引換券)
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const me = await res.json();
showUser(me);
return me;
}
/** ヘッダーに名前を出し、権限のないメニューを隠す */
function showUser(me) {
document.querySelector('#user-name').textContent = `${me.name}(${ROLE[me.role]})`;
for (const el of document.querySelectorAll('[data-role]')) {
el.hidden = el.dataset.role !== me.role; // 隠すだけ。守るのはサーバー(第11回の七)
}
}
// ログアウト
document.querySelector('#logout')?.addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
location.replace('/login.html');
});
- 401 ならログイン画面へ ── /api/me の返事が 401(ログインしていない)なら、いまの場所を next に入れてログイン画面へ
- 返事の来ない引換券 ── new Promise(() => {}) は、いつまでも返事が来ない Promise。これを返すと、呼んだ側の await requireLogin() の続きは動かない。画面が切り替わるまでのあいだに、空のデータで表を描いたりしない
- 名前は textContent で ── サーバーから来た名前を、第8回の約束どおり textContent で出す
申請フォームの送信(第10回)にも、401 のときの扱いを足しました。書いている途中でログインの期限が切れても、入力した内容は消さずに「もう一度ログインしてください」と知らせます。
隠すのと、断るのは別
「承認」のメニューは承認者にしか要りません。メニューの li に data-role="approver" と hidden を書いておき、showUser が承認者のときだけ hidden を外します。
でも、これは見た目だけです。メニューが隠れていても、アドレス欄に approve.html と打てば、画面は開けてしまいます(二で見たとおり、HTML はだれでも取れる)。だから承認の画面のデータは、サーバーが渡すかどうかを決めます。
サーバーの関所:401 と 403
// 経費精算システム:承認の画面(第11回)
// 承認者だけが使える。そうでない人には、サーバーが 403 を返す
async function showApprovals() {
await requireLogin();
const res = await fetch('/api/approvals');
if (res.status === 403) { // 403 = ログインはしているが、権限がない
const note = document.querySelector('#forbidden');
note.textContent = 'この画面を見る権限がありません(承認者だけが使えます)。';
note.hidden = false;
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// …
}
401 は「あなたがだれか分からない」(ログイン画面へ)、403 は「あなたがだれかは分かったが、それはできない」(ログインし直しても無駄なので、そう伝える)。似た数字ですが、画面のするべきことが違います。
新井AI に「承認者だけ承認ボタンを表示して」と頼んだら、ボタンを隠すコードだけ書いてくれました。
古井頼んだとおりだな。「隠して」と頼んだら隠してくれる。「守って」とは頼んでいない。
合言葉をしまう場所
ネットの記事では、ログインの印(トークン)を localStorage にしまう書き方もよく見かけます。HttpOnly のクッキーと比べてみました。
# 置き場所の違い(Chromium で実測) localStorage.setItem('token', …) → ほかのスクリプトからも読めた:'secret-123' ログイン後の document.cookie → '' (HttpOnly のクッキーは JavaScript から見えない) そのあとの fetch('/api/me') → 200(クッキーはブラウザが自動で付けて送る) # サーバーが渡したクッキー:sid(36 文字)・HttpOnly・SameSite=Lax
HttpOnly のクッキーと localStorage
localStorage に入れたものは、そのページで動くどのスクリプトからも読めます。第8回の XSS のように、よそのスクリプトが一度でも動いてしまえば、合言葉は持ち出されます。HttpOnly のクッキーなら JavaScript からは見えず、それでもブラウザが自動で送ってくれます。合言葉は JavaScript に触らせないのが、いにしえからの手堅いやり方です。
動かしてみる
練習用サーバーを立て、Playwright で実際に入力・クリックしました。途中でサーバー側のログインをすべて消し、「期限切れ」も起こしています。
# 実際に操作して記録した値(Chromium。サーバーは練習用) 操作 着いた URL ヘッダー 画面 ログインせずに申請一覧を開く /login.html?next=%2Findex.html ― ― パスワード違い /login.html?next=%2Findex.html ― メールアドレスかパスワードが違います。 メールアドレス違い /login.html?next=%2Findex.html ― メールアドレスかパスワードが違います。 新井でログイン /index.html 新井 太郎(申請者) 一覧 5 件・承認待ち 79,251 円・差し戻し 1 件 承認の画面を URL で開く /approve.html 新井 太郎(申請者) この画面を見る権限がありません(承認者だけが使えます)。 (ログインが切れた後)下書き保存 /form.html 新井 太郎(申請者) ログインの有効期限が切れました。もう一度ログインしてください(入力した内容は、この画面に残っています)。 (切れたまま)承認の画面を開く /login.html?next=%2Fapprove.html ― ― 古井でログイン /approve.html 古井 一郎(承認者) 承認待ち 3 件 「ログアウト」 /login.html ― ―
- ログインせずに申請一覧を開くと、next=%2Findex.html 付きでログイン画面へ。ログインすると申請一覧に戻った
- 新井さんの一覧は自分の5件だけ。山田さんの申請は、サーバーが最初から渡していない
- 新井さんが承認の画面を URL で開くと、画面は開くがデータは 403。メッセージで「権限がない」と伝えた
- 古井さんでログインすると「承認」のメニューが現れ、承認待ちの3件(新井さん2件・山田さん1件)が並んだ
ログインの本体はサーバーの仕事です。本物にするなら、パスワードをそのまま保存しない(ハッシュにする)、何度も間違えたら待たせる、https にして Secure を付ける、といった対策が要ります。画面の仕事は、だれが使っているかを見せ、行けない場所を案内し、失敗を分かる言葉で伝えること。守ってくれるのはサーバー、案内するのは画面です。
結 — 一枚まとめと次回予告
- 画面は HTML ファイルごとに分け、リンクで切り替える。HTML はだれでも取れる。守るのはデータ。
- ログインしたら、サーバーがHttpOnly のクッキーで合言葉を預ける。ブラウザが自動で送るので、JavaScript では触らない。
- どの画面も最初に requireLogin()。401 なら next 付きでログイン画面へ、戻りは location.replace。
- 戻り先は new URL() で読んで origin を比べる。「/ で始まる」では、// や /\ にだまされる。
- メニューを隠すのは親切、断るのはサーバー。401 は「だれか分からない」、403 は「それはできない」。
新井古井先輩の画面に、私のニューヨーク出張が並んでました! 承認してください!
古井まだボタンがない。次回は承認の流れ。承認・差し戻し、コメント、状態の移り変わりを作る。同じ申請を二人が同時に承認しようとしたらどうなるか、も試すぞ。
参考
- MDN — Using HTTP cookies
- MDN — Set-Cookie header
- MDN — HTTP response status codes (401, 403)
- MDN — URL: origin property
- MDN — Same-origin policy
- MDN — HTML attribute: autocomplete
- MDN — Passwords (Security)
- OWASP — Unvalidated Redirects and Forwards Cheat Sheet
- OWASP — Session Management Cheat Sheet
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月17日にクラウド上の Chromium(Playwright)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・パスワード・申請は、練習用サーバーの架空のデータです。着いた URL・ヘッダーの表示・メッセージ、状態コード、戻り先の判定、クッキーの印、localStorage の実験、「戻る」の履歴は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。各ページの HTML は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第11回 — ログインと画面の切り替え / 2026.10