Linux中國

無密碼驗證:客戶端

我們繼續 無密碼驗證 的文章。上一篇文章中,我們用 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 文件。

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

import Router from &apos;https://unpkg.com/@nicolasparada/router&apos;
import { isAuthenticated } from &apos;./auth.js&apos;

const router = new Router()

router.handle(&apos;/&apos;, guard(view(&apos;home&apos;)))
router.handle(&apos;/callback&apos;, view(&apos;callback&apos;))
router.handle(/^//, view(&apos;not-found&apos;))

router.install(async resultPromise => {
    document.body.innerHTML = &apos;&apos;
    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(&apos;welcome&apos;)) {
    return (...args) => isAuthenticated()
        ? fn1(...args)
        : fn2(...args)
}

與上篇文章不同的是,我們實現了一個 isAuthenticated() 函數和一個 guard() 函數,使用它去渲染兩種驗證狀態的頁面。因此,當用戶訪問 / 時,它將根據用戶是否通過了驗證來展示主頁或者是歡迎頁面。

驗證

現在,我們來編寫 isAuthenticated() 函數。使用下面的內容來創建一個 static/js/auth.js 文件:

export function getAuthUser() {
    const authUserItem = localStorage.getItem(&apos;auth_user&apos;)
    const expiresAtItem = localStorage.getItem(&apos;expires_at&apos;)

    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(&apos;jwt&apos;) !== null && getAuthUser() !== null
}

當有人登入時,我們將保存 JSON 格式的 web 令牌、它的過期日期,以及在 localStorage 上的當前已驗證用戶。這個模塊就是這個用處。

  • getAuthUser() 用於從 localStorage 獲取已認證的用戶,以確認 JSON 格式的 Web 令牌沒有過期。
  • isAuthenticated() 在前面的函數中用於去檢查它是否沒有返回 null

獲取

在繼續這個頁面之前,我將寫一些與伺服器 API 一起使用的 HTTP 工具。

我們使用以下的內容去創建一個 static/js/http.js 文件:

import { isAuthenticated } from &apos;./auth.js&apos;

function get(url, headers) {
    return fetch(url, {
        headers: Object.assign(getAuthHeader(), headers),
    }).then(handleResponse)
}

function post(url, body, headers) {
    return fetch(url, {
        method: &apos;POST&apos;,
        headers: Object.assign(getAuthHeader(), { &apos;content-type&apos;: &apos;application/json&apos; }, headers),
        body: JSON.stringify(body),
    }).then(handleResponse)
}

function getAuthHeader() {
    return isAuthenticated()
        ? { authorization: `Bearer ${localStorage.getItem(&apos;jwt&apos;)}` }
        : {}
}

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 === &apos;object&apos; && body !== null && &apos;message&apos; in body
            ? body.message
            : typeof body === &apos;string&apos; && body !== &apos;&apos;
                ? 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 文件:

const template = document.createElement(&apos;template&apos;)
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(&apos;access-form&apos;)
        .addEventListener(&apos;submit&apos;, onAccessFormSubmit)

    return page
}

這個頁面使用一個 HTMLTemplateElement 作為視圖。這只是一個輸入用戶 email 的簡單表單。

為了避免干擾,我將跳過錯誤處理部分,只是將它們輸出到控制台上。

現在,我們來寫 onAccessFormSubmit() 函數。

import http from &apos;../http.js&apos;

function onAccessFormSubmit(ev) {
    ev.preventDefault()

    const form = ev.currentTarget
    const input = form.querySelector(&apos;input&apos;)
    const email = input.value

    sendMagicLink(email).catch(err => {
        console.error(err)
        if (err.statusCode === 404 && wantToCreateAccount()) {
            runCreateUserProgram(email)
        }
    })
}

function sendMagicLink(email) {
    return http.post(&apos;/api/passwordless/start&apos;, {
        email,
        redirectUri: location.origin + &apos;/callback&apos;,
    }).then(() => {
        alert(&apos;Magic link sent. Go check your email inbox.&apos;)
    })
}

function wantToCreateAccount() {
    return prompt(&apos;No user found. Do you want to create an account?&apos;)
}

它對 /api/passwordless/start 發起了 POST 請求,請求體中包含 emailredirectUri。在本例中它返回 404 Not Found 狀態碼時,我們將創建一個用戶。

function runCreateUserProgram(email) {
    const username = prompt("Enter username")
    if (username === null) return

    http.post(&apos;/api/users&apos;, { 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 &apos;../http.js&apos;

const template = document.createElement(&apos;template&apos;)
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(&apos;jwt&apos;)
    const expiresAt = fragment.get(&apos;expires_at&apos;)

    http.get(&apos;/api/auth_user&apos;, { authorization: `Bearer ${jwt}` })
        .then(res => res.body)
        .then(authUser => {
            localStorage.setItem(&apos;jwt&apos;, jwt)
            localStorage.setItem(&apos;auth_user&apos;, JSON.stringify(authUser))
            localStorage.setItem(&apos;expires_at&apos;, expiresAt)

            location.replace(&apos;/&apos;)
        })
        .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 &apos;../auth.js&apos;

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

    const template = document.createElement(&apos;template&apos;)
    template.innerHTML = `
        <h1>Passwordless Demo</h1>
        <p>Welcome back, ${authUser.username} 

本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國