【前端】全栈开发06-账号密码的登录认证和token校验

389 阅读7分钟

在当前的开发模式中,大多数中后台系统仍然以账号密码登录作为主要的用户身份认证方式。在前后端分离的项目架构中,登录成功后由后端返回的 Token 通常作为鉴权的核心手段。这种方式因其安全性、灵活性和易用性,已成为主流选择。

以下将详细介绍在前端如何实现以下功能:

  • 账号密码登录
  • 密码加密传输
  • 请求携带 Token
  • Token 刷新机制

00 开始

方案分为前端和后端,前端基于react、axios、zustand。后端基于Spring Boot。 前后端代码已在github开源。

前端项目地址: web
后端项目地址:server
项目演示地址:xryder
账号:admin
密码:admin123

01 账号密码登录

在账号密码登录的实现中,用户输入的账号和密码通过表单提交到后端接口,后端验证身份后返回认证 Token。前端需要实现以下流程:

  1. 收集用户输入的账号和密码。
  2. 调用后端登录接口,传递账号和加密后的密码。
  3. 成功登录后,保存后端返回的 Token(通常是 JWT)。

进入登录页之后,调用后台接口获取publicKey。publicKey由后台代码生成,每次调用不在固定,防止publicKey泄露导致的风险。

获取publicKey之后,可以通过zustand保存在store中,也可以存储在LocalStore中。 使用publicKey对密码进行加密。这里需要安装加密组件: jsencrypt。

React代码:

import { JSEncrypt } from 'jsencrypt';

export const encryptPassword = (password, publicKey) => {
    const encrypt = new JSEncrypt();
    encrypt.setPublicKey(publicKey);
    const encryptedPassword = encrypt.encrypt(password);
    return encryptedPassword;
};

登录逻辑按照正常的接口调用提交后台请求,将返回结果中的token和refreshToken保存到zustand的store中。

一般情况下,我们在开发的前端项目中,都会定义一个Layout布局页面,当登录成功后,首先会渲染这个布局页面,然后渲染其中的children子页面。这个时候,我们在Layout页面中携带token请求用户信息,如果可以正常请求到用户信息,则继续渲染页面,如果报错,比如401,则跳转到登录页面进行登录。

02 请求拦截

请求拦截主要分为请求前拦截和获取返回结果之后的拦截。我们可以通过封装axios请求来实现这个功能。

请求前拦截,我们一般会在特定请求的请求头中添加token。

返回结果的拦截,主要是用来处理返回结果,比如正常的返回怎么处理结果,500错误,4xx错误的处理等等。这里我们还需要处理token过期,缓存请求,请求刷新token的操作之后再回放之前没发出的请求的操作。

这是请求拦截的示例代码:

import axios, {AxiosError, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig} from 'axios';
import qs from 'qs'
const ignoreMsgs = [
    '无效的刷新令牌', // 刷新令牌被删除时,不用提示
    '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面
]

let requestList: any[] = []
// 是否正在刷新中
let isRefreshToken = false
// 请求白名单,无须token的接口
const whiteList: string[] = ['/api/v1/token', '/login', '/v1/publicKey']
const api = axios.create({
    baseURL: '/api', // 使用配置文件中的 API 地址
});

// request拦截器
api.interceptors.request.use(
    (config: InternalAxiosRequestConfig) => {
        // 是否需要设置 token
        let isToken = true
        whiteList.some((v) => {
            if (config.url && config.url == v) {
                return isToken = false
            }
        })
        if (localStorage.getItem('token') && isToken) {
            config.headers.Authorization = 'Bearer ' + localStorage.getItem('token') // 让每个请求携带自定义token
        }

        const params = config.params || {}
        const data = config.data || false
        if (
            config.method?.toUpperCase() === 'POST' &&
            (config.headers as AxiosRequestHeaders)['Content-Type'] ===
            'application/x-www-form-urlencoded'
        ) {
            config.data = qs.stringify(data)
        }
        // get参数编码
        if (config.method?.toUpperCase() === 'GET' && params) {
            config.params = {}
            const paramsStr = qs.stringify(params, { allowDots: true })
            if (paramsStr) {
                config.url = config.url + '?' + paramsStr
            }
        }
        return config
    },
    (error: AxiosError) => {
        // Do something with request error
        console.log(error) // for debug
        Promise.reject(error)
    }
)

// response 拦截器
api.interceptors.response.use(
    async (response: AxiosResponse<any>) => {
        let { data, status } = response
        if (status == 500) {
            await handleError()
            return Promise.reject('服务器异常!')
        }
        const config = response.config
        if (!data) {
            // 返回“[HTTP]请求没有返回值”;
            throw new Error()
        }
        // 未设置状态码则默认成功状态
        // 二进制数据则直接返回,例如说 Excel 导出
        if (
            response.request.responseType === 'blob' ||
            response.request.responseType === 'arraybuffer'
        ) {
            // 注意:如果导出的响应为 json,说明可能失败了,不直接返回进行下载
            if (response.data.type !== 'application/json') {
                return response.data
            }
            data = await new Response(response.data).json()
        }
        const code = data.code
        // 获取错误信息
        const msg = data.msg
        if (ignoreMsgs.indexOf(msg) !== -1) {
            // 如果是忽略的错误码,直接返回 msg 异常
            return Promise.reject(msg)
        } else if (code === 405) {
            // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
            if (!isRefreshToken) {
                isRefreshToken = true
                // 1. 如果获取不到刷新令牌,则只能执行登出操作
                if (!localStorage.getItem("refreshToken")) {
                    return handleAuthorized()
                }
                // 2. 进行刷新访问令牌
                try {
                    const refreshTokenRes = await refreshToken()
                    if (refreshTokenRes.data.code == 200) {
                        // 2.1 刷新成功,则回放队列的请求 + 当前请求
                        localStorage.setItem('token', (await refreshTokenRes).data.data)
                        config.headers!.Authorization = 'Bearer ' + localStorage.getItem('token')
                        requestList.forEach((cb: any) => {
                            cb()
                        })
                        requestList = []
                        return api(config)
                    } else {
                        return handleAuthorized()
                    }

                } catch (e) {
                    // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
                    // 2.2 刷新失败,只回放队列的请求
                    requestList.forEach((cb: any) => {
                        cb()
                    })
                    // 提示是否要登出。即不回放当前请求!不然会形成递归
                    return handleAuthorized()
                } finally {
                    requestList = []
                    isRefreshToken = false
                }
            } else {
                // 添加到队列,等待刷新获取到新的令牌
                return new Promise((resolve) => {
                    requestList.push(() => {
                        config.headers!.Authorization = 'Bearer ' + localStorage.getItem("token")
                        resolve(api(config))
                    })
                })
            }
        } else if (code === 500) {
            return handleError()
        } else if (code === 403) {
            return handleForbidden()
        } else if (code === 406) {
            return handleAuthorized()
        } else if (code === 401) {
            if (!window.location.href.includes('login')) {
                window.location.href = '/login'
            }
            localStorage.removeItem('token')
            localStorage.removeItem('refreshToken')
            return data
        } else {
            return data
        }
    },
    (error: AxiosError) => {
        handleError()
        return Promise.reject(error)
    }
)

const refreshToken = async () => {
    return await axios.get('/api/v1/token?refreshToken=' + localStorage.getItem('refreshToken'))
}

const handleAuthorized = () => {
    // 如果已经到重新登录页面则不进行弹窗提示
    if (window.location.href.includes('login')) {
        return
    }
    localStorage.removeItem('token')
    localStorage.removeItem('refreshToken')
    window.location.href = '/login'
    return Promise.reject('认证失败')
}

const handleForbidden = () => {
    window.location.href = '/403'
    return Promise.reject('未授权访问!')
}

// 开发阶段可以关闭跳转,如果打卡跳转500错误页面,错误信息就没法获取
const handleError = () => {
    // window.location.href = '/500'
}

export default api;

token一般设置较短的有效期,比如3分钟,而refreshToken会有一个更长的有效期,比如15天,当后端返回token失效的信息后,前端的返回结果拦截处理器就会调用刷新token的接口使用refreshToken 去请求新的token,并再次发送之前因token失效而失败的请求。

03 接口状态码设计

前后端的对接一般都需要双方协商好一致的返回状态码,这个就像Http Status,但我们在设计接口返回Code时,一般不使用HttpStatus作为我们的接口状态码,因为这个无法反映具体的业务情况。

我们认为只要后端收到了请求,我们的HttpStatus都返回200。当登录失败或者鉴权失败后,我们的HttpStatus还是200,但我们自定义的返回结果中的Code可以设计为401或者其他可以代表登录失败的代码,如4001之类的。

这是一个自定义的接口返回code的参考:

public enum ResultCode {
    /*
    请求返回状态码和说明信息
     */
    SUCCESS(200, "成功"),
    BAD_REQUEST(400, "参数或者语法不对"),
    UNAUTHORIZED(401, "认证失败"),
    FORBIDDEN(403, "禁止访问"),
    NOT_FOUND(404, "请求的资源不存在"),
    INVALID_REFRESH_TOKEN(406, "无效的refreshToken"),
    TOKEN_EXPIRED(405, "token过期"),
    CONFLICT(409, "资源已存在"),
    SQL_ERROR(410, "sql执行错误"),
    SERVER_ERROR(500, "服务器内部错误"),

    ;
    private final int code;
    private final String msg;

    ResultCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

}

04 理论结合实践

实现不是一成不变的,只要掌握到核心概念,就可以根据这个概念按照自身当前的情况进行灵活的工程实现。我这里只是提供了我的一个实现参考,不足之处,欢迎指出。

05 联系我 📬

你可以通过这些方式跟我联系:

感谢你在我的互联网角落停留片刻! 💫