我们继续 无密码验证 的文章。上一篇文章中,我们用 Go 写了一个 HTTP 服务,用这个服务来做无密码验证 API。今天,我们为它再写一个 JavaScript 客户端。
我们将使用 这里的 这个单页面应用程序(SPA)来展示使用的技术。如果你还没有读过它,请先读它。
记住流程:
- 用户输入其 email。
- 用户收到一个带有魔法链接的邮件。
- 用户点击该链接、
- 用户验证成功。
对于根 URL(/
),我们将根据验证的状态分别使用两个不同的页面:一个是带有访问表单的页面,或者是已验证通过的用户的欢迎页面。另一个页面是验证回调的重定向页面。
伺服
我们将使用相同的 Go 服务器来为客户端提供服务,因此,在我们前面的 main.go
中添加一些路由:
1 2
| router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")}))
|
1 2 3 4 5 6 7 8 9 10 11 12
| type SPAFileSystem struct { fs http.FileSystem }
func (spa SPAFileSystem) Open(name string) (http.File, error) { f, err := spa.fs.Open(name) if err != nil { return spa.fs.Open("index.html") } return f, nil }
|
这个伺服文件放在 static
下,配合 static/index.html
作为回调。
你可以使用你自己的服务器,但是你得在服务器上启用 CORS。
HTML
我们来看一下那个 static/index.html
文件。
1 2 3 4 5 6 7 8 9 10 11 12
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Passwordless Demo</title> <link rel="shortcut icon" href="data:,"> <script src="/js/main.js" type="module"></script> </head> <body></body> </html>
|
单页面应用程序的所有渲染由 JavaScript 来完成,因此,我们使用了一个空的 body 部分和一个 main.js
文件。
我们将使用 上篇文章 中的 Router。
渲染
现在,我们使用下面的内容来创建一个 static/js/main.js
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import Router from 'https://unpkg.com/@nicolasparada/router' import { isAuthenticated } from './auth.js'
const router = new Router()
router.handle('/', guard(view('home'))) router.handle('/callback', view('callback')) router.handle(/^\//, view('not-found'))
router.install(async resultPromise => { document.body.innerHTML = '' document.body.appendChild(await resultPromise) })
function view(name) { return (...args) => import(`/js/pages/${name}-page.js`) .then(m => m.default(...args)) }
function guard(fn1, fn2 = view('welcome')) { return (...args) => isAuthenticated() ? fn1(...args) : fn2(...args) }
|
与上篇文章不同的是,我们实现了一个 isAuthenticated()
函数和一个 guard()
函数,使用它去渲染两种验证状态的页面。因此,当用户访问 /
时,它将根据用户是否通过了验证来展示主页或者是欢迎页面。
验证
现在,我们来编写 isAuthenticated()
函数。使用下面的内容来创建一个 static/js/auth.js
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| export function getAuthUser() { const authUserItem = localStorage.getItem('auth_user') const expiresAtItem = localStorage.getItem('expires_at')
if (authUserItem !== null && expiresAtItem !== null) { const expiresAt = new Date(expiresAtItem)
if (!isNaN(expiresAt.valueOf()) && expiresAt > new Date()) { try { return JSON.parse(authUserItem) } catch (_) { } } }
return null }
export function isAuthenticated() { return localStorage.getItem('jwt') !== null && getAuthUser() !== null }
|
当有人登入时,我们将保存 JSON 格式的 web 令牌、它的过期日期,以及在 localStorage
上的当前已验证用户。这个模块就是这个用处。
getAuthUser()
用于从 localStorage
获取已认证的用户,以确认 JSON 格式的 Web 令牌没有过期。
isAuthenticated()
在前面的函数中用于去检查它是否没有返回 null
。
获取
在继续这个页面之前,我将写一些与服务器 API 一起使用的 HTTP 工具。
我们使用以下的内容去创建一个 static/js/http.js
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| import { isAuthenticated } from './auth.js'
function get(url, headers) { return fetch(url, { headers: Object.assign(getAuthHeader(), headers), }).then(handleResponse) }
function post(url, body, headers) { return fetch(url, { method: 'POST', headers: Object.assign(getAuthHeader(), { 'content-type': 'application/json' }, headers), body: JSON.stringify(body), }).then(handleResponse) }
function getAuthHeader() { return isAuthenticated() ? { authorization: `Bearer ${localStorage.getItem('jwt')}` } : {} }
export async function handleResponse(res) { const body = await res.clone().json().catch(() => res.text()) const response = { statusCode: res.status, statusText: res.statusText, headers: res.headers, body, } if (!res.ok) { const message = typeof body === 'object' && body !== null && 'message' in body ? body.message : typeof body === 'string' && body !== '' ? body : res.statusText const err = new Error(message) throw Object.assign(err, response) } return response }
export default { get, post, }
|
这个模块导出了 get()
和 post()
函数。它们是 fetch
API 的封装。当用户是已验证的,这二个函数注入一个 Authorization: Bearer <token_here>
头到请求中;这样服务器就能对我们进行身份验证。
欢迎页
我们现在来到欢迎页面。用如下的内容创建一个 static/js/pages/welcome-page.js
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const template = document.createElement('template') template.innerHTML = ` <h1>Passwordless Demo</h1> <h2>Access</h2> <form id="access-form"> <input type="email" placeholder="Email" autofocus required> <button type="submit">Send Magic Link</button> </form> `
export default function welcomePage() { const page = template.content.cloneNode(true)
page.getElementById('access-form') .addEventListener('submit', onAccessFormSubmit)
return page }
|
这个页面使用一个 HTMLTemplateElement
作为视图。这只是一个输入用户 email 的简单表单。
为了避免干扰,我将跳过错误处理部分,只是将它们输出到控制台上。
现在,我们来写 onAccessFormSubmit()
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import http from '../http.js'
function onAccessFormSubmit(ev) { ev.preventDefault()
const form = ev.currentTarget const input = form.querySelector('input') const email = input.value
sendMagicLink(email).catch(err => { console.error(err) if (err.statusCode === 404 && wantToCreateAccount()) { runCreateUserProgram(email) } }) }
function sendMagicLink(email) { return http.post('/api/passwordless/start', { email, redirectUri: location.origin + '/callback', }).then(() => { alert('Magic link sent. Go check your email inbox.') }) }
function wantToCreateAccount() { return prompt('No user found. Do you want to create an account?') }
|
它对 /api/passwordless/start
发起了 POST 请求,请求体中包含 email
和 redirectUri
。在本例中它返回 404 Not Found
状态码时,我们将创建一个用户。
1 2 3 4 5 6 7 8 9 10
| function runCreateUserProgram(email) { const username = prompt("Enter username") if (username === null) return
http.post('/api/users', { email, username }) .then(res => res.body) .then(user => sendMagicLink(user.email)) .catch(console.error) }
|
这个用户创建程序,首先询问用户名,然后使用 email 和用户名做一个 POST
请求到 /api/users
。成功之后,给创建的用户发送一个魔法链接。
回调页
这是访问表单的全部功能,现在我们来做回调页面。使用如下的内容来创建一个 static/js/pages/callback-page.js
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import http from '../http.js'
const template = document.createElement('template') template.innerHTML = ` <h1>Authenticating you</h1> `
export default function callbackPage() { const page = template.content.cloneNode(true)
const hash = location.hash.substr(1) const fragment = new URLSearchParams(hash) for (const [k, v] of fragment.entries()) { fragment.set(decodeURIComponent(k), decodeURIComponent(v)) } const jwt = fragment.get('jwt') const expiresAt = fragment.get('expires_at')
http.get('/api/auth_user', { authorization: `Bearer ${jwt}` }) .then(res => res.body) .then(authUser => { localStorage.setItem('jwt', jwt) localStorage.setItem('auth_user', JSON.stringify(authUser)) localStorage.setItem('expires_at', expiresAt)
location.replace('/') }) .catch(console.error)
return page }
|
请记住……当点击魔法链接时,我们会来到 /api/passwordless/verify_redirect
,它将把我们重定向到重定向 URI,我们将放在哈希中的 JWT 和过期日期传递给 /callback
。
回调页面解码 URL 中的哈希,提取这些参数去做一个 GET
请求到 /api/auth_user
,用 JWT 保存所有数据到 localStorage
中。最后,重定向到主页面。
主页
创建如下内容的 static/pages/home-page.js
文件:
1 2 3 4 5 6 7 8 9
| import { getAuthUser } from '../auth.js'
export default function homePage() { const authUser = getAuthUser()
const template = document.createElement('template') template.innerHTML = ` <h1>Passwordless Demo</h1> <p>Welcome back, ${authUser.username}
|