前端 - OAuth2 授权流程实操记录

0 阅读3分钟

一、OAuth2 实现细节流程包括

界面1(一般为登录页): 用户点击按钮或者界面初始化,开始授权

  • 1、给按钮添加点击事件,或者在界面初始化时调用授权函数

  • 2、在授权函数中,构造授权URL,包含必要的参数(client_id、redirect_uri、response_type等)

  • 3、使用 window.open() 打开授权URL,用户在新窗口中完成授权

界面2(重定向页或者授权页): 授权回调处理(通常为 redirect 界面)

  • 1、在 redirect_uri 指定的页面中,解析 URL 中的授权结果(如 code 或 token)

  • 2、如果授权成功,使用 code 换取 access_token(如果是授权码模式)

  • 3、将 access_token 存储在本地(如 localStorage)以供后续 API 请求使用

  • 4、处理授权失败的情况,给用户反馈错误信息

注意事项

  • 确保 redirect_uri 已在 OAuth2 提供者注册,并且与授权URL中的一致

  • 处理好授权窗口的关闭和用户取消授权的情况

  • 注意安全性,避免泄露 client_id 和 access_token

  • State 校验:千万别漏掉 state 校验,这是区分初级和高级开发的分水岭,能防御 CSRF 攻击。

  • History 模式:OAuth 回调地址对 # 号支持极差,优先使用 createWebHistory。

  • 安全性:client_secret 绝不能出现在前端代码中。

  • 体验感:通过 pre_auth_url 记录用户原始意图,登录后自动跳回,体验极佳。

二、具体实现

以下例子基于 vue3+ts+vueRouter 进行实现

login.vue 界面代码

负责构造复杂的授权 URL 并跳转.(不要在前端硬编码授权地址。建议从后端接口获取授权 URL,或者在配置中动态拼接。)

<!-- login.vue 界面代码 -->
<template>

    <button @click="startOAuth">Login with OAuth2</button>

</template>

<script lang="ts" setup>

    const startOAuth = () => {
        const client_id = 'your_client_id';
        const redirect_uri = encodeURIComponent('http://10.10.10.10:8080/redirect'); // 授权地址需要提供给相关人员进行配置
        const state = Math.random().toString(36).substring(7); // state 用于防止 CSRF 攻击,通常是一个随机字符串
        sessionStorage.setItem('auth_state', state); // 存储 state 用于回调校验
        const authUrl = `https://oauth-server.com/auth?response_type=code&client_id=${client_id}&redirect_uri=${redirect_uri}&state=${state}`;
        window.location.href = authUrl;
    }
</script>

redirect.vue 界面代码(或者是授权界面)

这是一个“无感”中转页,只负责逻辑处理。这个页面不需要复杂的 UI,通常展示一个加载动画。它的任务是:解析 URL -> 校验 State -> 换取 Token -> 存储 -> 跳转。

核心逻辑:

  1. 获取参数:  从 URL 中提取 code 和 state
  2. 校验 State:  对比本地存储的 state,如果不一致,拒绝授权(防止跨站请求伪造)。
  3. 换取 Token:
    • 方案 A (推荐):  将 code 发给自有后端,由后端去授权服务器换取 access_token。这样可以隐藏 client_secret,确保安全。
    • 方案 B (隐式授权):  直接从 URL 锚点获取 Token(安全性较低,OAuth 2.1 已不推荐)。 存储与全局拦截
  • 存储:  将 access_token 和 refresh_token 存入 localStorage 或 Cookie(设置 HttpOnly 更安全)。
  • 状态更新:  更新全局 Store(如 Pinia 或 Redux)中的用户信息或者存储其他信息。
  • 路由跳转:  router.push('/projects')
<!-- redirect.vue 界面代码 -->
<template>
    <div>重定向中...(正在验证权限,请稍候...)</div>
</template>

<script lang="ts" setup>
    import { onMounted } from 'vue';
    import { useRouter } from 'vue-router';

    const router = useRouter()
    onMounted(() => {
        let params = new URLSearchParams(window.location.search)
        const code = params.get('code') || ''
        const savedState = sessionStorage.getItem('auth_state');
        if(!code || state !== savedState) {
            return router.replace('/login')
        }
        getToken(code)
    })

    const getToken = (code) => {
        let res: any = await allApi.oauth_token_api({
            code: new URLSearchParams(window.location.search).get('code') || '',
            client_id: localStorage.getItem('oauth-client-id'),
        }).catch(err => {
            router.replace('/login')
            message.error(err?.message || err || '登录失败,请检查 Token 是否正确,网络是否正常。')
        })

        // 存储token 和 用户信息
        localStorage.setItem('access_token', res.access_token);
        localStorage.setItem('refresh_token', res.refresh_token);
        localStorage.setItem('userInfo', JSON.stringify(res.user || {}))

        // 跳转界面
        const target = localStorage.getItem('pre_auth_url') || '/projects';
        localStorage.removeItem('pre_auth_url');
        router.replace(target)
    }

    // 重定向到登录页面
    export const redirectToLogin = (): void => {
        router.replace('/login')
    }

router 配置

这里定义了三个关键节点:登录页、回调处理页(Hidden)、业务主页。

// src/router.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';

const routes: RouteRecordRaw[] = [
    { path: '/', redirect: '/projects' },
    {
        path: '/login',
        name: 'Login',
        component: () => import('@/views/Login.vue'),
        meta: { public: true }
    },
    {
        path: '/redirect',
        name: 'Redirect',
        component: () => import('@/views/Redirect.vue'),
        meta: { public: true }
    },
    {
        path: '/projects',
        name: 'Projects',
        component: () => import('@/views/Projects.vue'),
        meta: { requiresAuth: true }
    }
];

export const router = createRouter({
    history: createWebHistory(), // 强烈建议 OAuth2 使用 History 模式避免 # 号解析问题
    routes
});

// 路由守卫:核心拦截逻辑
router.beforeEach((to, from, next) => {
    const token = localStorage.getItem('access_token');
    if (to.meta.requiresAuth && !token) {
        // 没登录,想去受保护页面:记录当前路径并踢到登录页
        localStorage.setItem('pre_auth_url', to.fullPath);
        next('/login');
    } else if (to.path === '/login' && token) {
        // 已登录还想去登录页:直接去首页
        next('/projects');
    } else {
        next();
    }
});

axios 请求封装示例

OAuth2 的精髓在于 access_token 短期有效,refresh_token 长期有效。

import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
    skipAuth?: boolean
}

const service = axios.create({
    baseURL: '/',
    timeout: 600000,
    headers: { 'Content-Type': 'application/json;charset=utf-8' }
});

service.interceptors.request.use(config => {
    if (config?.skipAuth) return config // 接口本身无需用户验证(如登录、刷新 token),则跳过 token 注入

    const token = localStorage.getItem('access_token');
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
});

service.interceptors.response.use(
    response => {
        return Promise.resolve(response?.data?.data || response?.data || response)
    },

    async error => {
        const originalRequest = error.config;
        // 如果返回 401 且不是刷新 token 的接口本身
        if (error.response.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;
            const refreshToken = localStorage.getItem('refresh_token');
            if (refreshToken) {
                try {
                    // 尝试静默刷新 Token
                    const { data } = await axios.post('/api/auth/refresh', { refresh_token: refreshToken });
                    localStorage.setItem('access_token', data.access_token);
                    // 重新发起刚才失败的请求
                    originalRequest.headers.Authorization = `Bearer${data.access_token}`;
                    return service(originalRequest);
                } catch (refreshError) {
                    // 刷新也失败了,必须重新登录
                    localStorage.clear();
                    window.location.href = '/login';
                }
            }
        }
        return Promise.reject(error);
    }
);

  
export default function request<T>(url: string, options: AxiosRequestConfig = {}):Promise<AxiosResponse<T, any>> {
    options.url = url
    return service.request<T>(options)
}

allApi.ts 示例

import request from './requests'

export const info_api = async () => {
    return request('/api/info', {
        method: 'GET',
        withCredentials: true // 需要求带上 cookie 的接口(如获取用户信息)
    })

}
export const health_api = async () => {
    return request('/api/health', {
        method: 'GET'
    })
}

三、关注点总结

  1. State 校验:千万别漏掉 state 校验,能防御 CSRF 攻击。
  2. History 模式:OAuth 回调地址对 # 号支持极差,优先使用 createWebHistory
  3. 安全性client_secret 绝不能出现在前端代码中。
  4. 体验感:通过 pre_auth_url 记录用户原始意图,登录后自动跳回,体验极佳。