一、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 -> 存储 -> 跳转。
核心逻辑:
- 获取参数: 从 URL 中提取
code和state。 - 校验 State: 对比本地存储的
state,如果不一致,拒绝授权(防止跨站请求伪造)。 - 换取 Token:
- 方案 A (推荐): 将
code发给自有后端,由后端去授权服务器换取access_token。这样可以隐藏client_secret,确保安全。 - 方案 B (隐式授权): 直接从 URL 锚点获取 Token(安全性较低,OAuth 2.1 已不推荐)。 存储与全局拦截
- 方案 A (推荐): 将
- 存储: 将
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'
})
}
三、关注点总结
- State 校验:千万别漏掉
state校验,能防御 CSRF 攻击。 - History 模式:OAuth 回调地址对
#号支持极差,优先使用createWebHistory。 - 安全性:
client_secret绝不能出现在前端代码中。 - 体验感:通过
pre_auth_url记录用户原始意图,登录后自动跳回,体验极佳。