无密码验证:客户端
创始人
2024-03-01 23:29:50
0

我们继续 无密码验证 的文章。上一篇文章中,我们用 Go 写了一个 HTTP 服务,用这个服务来做无密码验证 API。今天,我们为它再写一个 JavaScript 客户端。

我们将使用 这里的 这个单页面应用程序(SPA)来展示使用的技术。如果你还没有读过它,请先读它。

记住流程:

  • 用户输入其 email。
  • 用户收到一个带有魔法链接的邮件。
  • 用户点击该链接、
  • 用户验证成功。

对于根 URL(/),我们将根据验证的状态分别使用两个不同的页面:一个是带有访问表单的页面,或者是已验证通过的用户的欢迎页面。另一个页面是验证回调的重定向页面。

伺服

我们将使用相同的 Go 服务器来为客户端提供服务,因此,在我们前面的 main.go 中添加一些路由:

router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")}))
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 文件。




 
 
 Passwordless Demo
 
 



单页面应用程序的所有渲染由 JavaScript 来完成,因此,我们使用了一个空的 body 部分和一个 main.js 文件。

我们将使用 上篇文章 中的 Router。

渲染

现在,我们使用下面的内容来创建一个 static/js/main.js 文件:

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 文件:

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 文件:

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 头到请求中;这样服务器就能对我们进行身份验证。

欢迎页

我们现在来到欢迎页面。用如下的内容创建一个 static/js/pages/welcome-page.js 文件:

const template = document.createElement('template')
template.innerHTML = `
    

Passwordless Demo

Access

` export default function welcomePage() { const page = template.content.cloneNode(true) page.getElementById('access-form') .addEventListener('submit', onAccessFormSubmit) return page }

这个页面使用一个 HTMLTemplateElement 作为视图。这只是一个输入用户 email 的简单表单。

为了避免干扰,我将跳过错误处理部分,只是将它们输出到控制台上。

现在,我们来写 onAccessFormSubmit() 函数。

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 请求,请求体中包含 emailredirectUri。在本例中它返回 404 Not Found 状态码时,我们将创建一个用户。

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 文件:

import http from '../http.js'

const template = document.createElement('template')
template.innerHTML = `
    

Authenticating you

` 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 文件:

import { getAuthUser } from '../auth.js'

export default function homePage() {
    const authUser = getAuthUser()

    const template = document.createElement('template')
    template.innerHTML = `
        

Passwordless Demo

Welcome back, ${authUser.username}

相关内容

中国空间站首次应用验证大模...
新华社北京8月16日电(李国利、占康)神舟二十号航天员乘组15日晚...
2025-08-16 18:48:08
Servlet与Filte...
Servlet的session授权方式 Hello Welcome...
2025-06-01 09:48:18
使用SpringBoot+...
文章目录前言一、JWT是什么?二、使用步骤1.创建项...
2025-06-01 00:09:35
前后端分离项目中实现注册业...
一,前言信息化的当前,信息安全性对于网...
2025-05-30 08:58:04
python seleni...
1.通过定位元素截取图片的方法进行识别#ocr识别原理࿱...
2025-05-29 12:40:18
SpringBoot实现业...
图形验证码的必要性图形验证码是验证码的一种,有防止黑...
2025-05-28 18:50:55

热门资讯

Helix:高级 Linux ... 说到 基于终端的文本编辑器,通常 Vim、Emacs 和 Nano 受到了关注。这并不意味着没有其他...
使用 KRAWL 扫描 Kub... 用 KRAWL 脚本来识别 Kubernetes Pod 和容器中的错误。当你使用 Kubernet...
JStock:Linux 上不... 如果你在股票市场做投资,那么你可能非常清楚投资组合管理计划有多重要。管理投资组合的目标是依据你能承受...
Epic 游戏商店现在可在 S... 现在可以在 Steam Deck 上运行 Epic 游戏商店了,几乎无懈可击! 但是,它是非官方的。...
《Apex 英雄》正式可在 S... 《Apex 英雄》现已通过 Steam Deck 验证,这使其成为支持 Linux 的顶级多人游戏之...
从 Yum 更新中排除特定/某... 作为系统更新的一部分,你也许需要在基于 Red Hat 系统中由于应用依赖排除一些软件包。如果是,如...
通过 SaltStack 管理... 我在搜索Puppet的替代品时,偶然间碰到了Salt。我喜欢puppet,但是我又爱上Salt了:)...
如何在 Github 上创建一... 学习如何复刻一个仓库,进行更改,并要求维护人员审查并合并它。你知道如何使用 git 了,你有一个 G...
Opera 浏览器内置的 VP... 昨天我们报道过 Opera 浏览器内置了 VPN 服务,用户打开它可以防止他们的在线活动被窥视。不过...
如何检查你的 Linux 系统... 不知道在使用哪个初始化系统?以下是方法。每个主流 Linux 发行版(包括 Ubuntu、Fedor...