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/… 取来填进去。要守护的不是页面,而是服务器返回的数据。本讲的思路全都从这里开始。
登录的机制:暗号
密码只发一次,之后用暗号表明身份
登录成功后,服务器在回复的头部加上 Set-Cookie,把暗号(会话 ID)交给浏览器保管。浏览器把它作为 Cookie 收好,向同一网站发请求时自动附上。服务器查自己手边的表,就知道“这个暗号是新井”。
| Cookie 的标记 | 意思 |
|---|---|
| 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) { // 故意不说是账号错还是密码错
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 “只给审批人显示审批按钮”,它只写了隐藏按钮的代码。
古井它照你说的做了。你说“隐藏”,它就隐藏。你没说“守护”。
暗号放在哪里
网上的文章里,也常见把登录凭证(token)放进 localStorage 的写法。我和 HttpOnly 的 Cookie 比较了一下。
# 存放位置的区别(Chromium 实测) localStorage.setItem('token', …) → 其他脚本也能读到:'secret-123' 登录后的 document.cookie → '' (HttpOnly 的 Cookie,JavaScript 看不到) 之后的 fetch('/api/me') → 200(Cookie 由浏览器自动附上发送) # 服务器给的 Cookie:sid(36 个字符)・HttpOnly・SameSite=Lax
HttpOnly 的 Cookie 与 localStorage
放进 localStorage 的东西,在该页面上运行的任何脚本都能读到。像第8讲的 XSS 那样,别人的脚本只要运行过一次,暗号就会被带走。HttpOnly 的 Cookie 对 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 的 Cookie 交出暗号。浏览器自动发送,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)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・密码・申请都是练习用服务器的虚构数据。到达的 URL・页头的显示・提示、状态码、返回地址的判断、Cookie 的标记、localStorage 的实验、“后退”的历史记录,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。各页面的 HTML 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第11讲 — 登录与页面切换 / 2026.10