vue2.0接入 认证中心(Orchard Core CMS)
29
简介:Vue 前端项目接入 Orchard Core 作为认证中心(OpenID Connect 认证服务器),通常会使用 OAuth 2.0 的授权码流 (Authorization Code Flow),特别是为了前端安全,还会结合 PKCE (Proof Key for Code Exchange) 扩展。
核心概念回顾:
OpenID Connect (OIDC): 在 OAuth 2.0 的基础上构建,用于身份认证。
授权码流 (Authorization Code Flow): 最安全的 OAuth 2.0 流,客户端(前端 Vue 应用)不会直接处理用户凭据或秘密。它从认证服务器获取一个临时的 "授权码",然后用这个授权码在后端(或通过 PKCE 直接在前端)安全地交换
access_token
和id_token
。PKCE (Proof Key for Code Exchange): 授权码流的一个安全扩展,特别适用于公共客户端(如 SPA 应用),即使授权码被拦截,也无法轻易被滥用,因为需要一个动态生成的 "验证器"。
id_token
: OIDC 特有的 JWT 令牌,包含用户的身份信息(如sub
用户ID,name
用户名,email
等),用于前端认证。access_token
: OAuth 2.0 令牌,用于访问受保护的 API 资源。refresh_token
: (可选) 用于在access_token
过期后,无需用户重新登录即可获取新的access_token
。
步骤一:在 Orchard Core 认证中心注册您的 Vue 前端应用作为客户端
1、登录 Orchard Core 后台管理界面:http://localhost:5233/Admin/
。
2、
最后点击保存即可!
步骤二:在 Vue 项目中集成 OIDC 客户端库
1、通过本机上的vue-cli 可以执行:vue create vue-oidc-test-app
2、安装必要依赖:npm install oidc-client axios vue-router@3
# 如果你还没安装axios和vue-router
3、配置 OIDC 客户端实例以及界面和路由
1️⃣: OIDC 实例 src/auth/oidc.js
// src/auth/oidc.js
// 对于 oidc-client v1.x.x,通常需要这样导入整个模块来访问其内部属性,包括 Log
import * as Oidc from 'oidc-client';
// 从导入的 Oidc 命名空间中获取 UserManager 和 WebStorageStateStore
const UserManager = Oidc.UserManager;
const WebStorageStateStore = Oidc.WebStorageStateStore;
// 从导入的 Oidc 命名空间中获取 Log 对象
const Log = Oidc.Log;
console.dir(Log.setLevel);
// 启用 oidc-client 的日志(开发时非常有用)
// Log.setLevel(Oidc.Log.DEBUG); // 使用 Oidc.Log.DEBUG 来设置级别 【没有找到此方法】
Log.level = Log.DEBUG; // 设置日志级别为 DEBUG。Log.DEBUG, Log.INFO, Log.WARN, Log.ERROR, Log.NONE 都是可用的常量。
Log.logger = console; // <--- **关键修改点:这里是 Log.logger = console**
const settings = {
authority: 'http://localhost:5233/', // 您的 Orchard Core 认证中心地址
client_id: 'mywebapi
', // 您在 Orchard Core 中注册的客户端 ID
redirect_uri: 'http://localhost:8080/callback', // 前端应用的认证成功回调 URL
response_type: 'code', // 授权码流
scope: 'openid profile api1 offline_access', // 请求的权限范围
post_logout_redirect_uri: 'http://localhost:8080/', // 登出后的重定向 URL
// PKCE 相关设置
code_challenge_method: 'S256',
// 存储用户会话信息的地方
userStore: new WebStorageStateStore({ store: window.localStorage }),
// 其他可选设置
automaticSilentRenew: true, // 自动静默刷新 access_token
// 需要配置 silent_redirect_uri
silent_redirect_uri: window.location.origin + '/silent-renew.html', // 指向一个空的 HTML 页面
monitorSession: true, // 监控会话状态
// ****** 添加/修改这一行 ******
// 让 oidc-client 在 Access Token "原始" 过期前 3500 秒 (即实际签发后 100 秒左右)就开始尝试刷新
// 这样它就会早于后端 10 秒过期时间进行刷新
accessTokenExpiringNotificationTime: 3500, // 默认是 60 秒
extraQueryParams: {
prompt: 'consent' // 在登录时强制显示同意页面
},
filterProtocolClaims: true, // 过滤协议声明
loadUserInfo: true, // 在获取 id_token 后自动加载用户信息
};
const userManager = new UserManager(settings);
// 监听用户事件(这里的事件名称和回调参数在新旧版本中通常是兼容的)
userManager.events.addUserLoaded((user) => {
console.log('User loaded:', user);
});
userManager.events.addUserUnloaded(() => {
console.log('User unloaded');
});
userManager.events.addAccessTokenExpiring(() => {
console.log('Access token expiring...');
});
userManager.events.addAccessTokenExpired(() => {
console.log('Access token expired.');
});
userManager.events.addSilentRenewError((error) => {
console.error('Silent renew error:', error);
});
export default userManager;
2️⃣:创建首页(views/Home.vue)
<!--
Copyright (c) 2025 zdb
This software is released under the MIT License.
https://opensource.org/licenses/MIT
-->
<template>
<div id="app-container">
<h1>Vue 2.0 OIDC 测试应用</h1>
<div v-if="!isAuthenticated" class="auth-section">
<p>您尚未登录。</p>
<button @click="login" class="btn primary">登录</button>
</div>
<div v-else class="auth-section">
<p>欢迎,**{{ userProfile.name || userProfile.sub }}**!</p>
<p>角色: <span v-if="userProfile.role">{{ userProfile.role.join(', ') }}</span> <span v-else>无</span></p>
<p>Access Token 有效期:{{ tokenExpirationRemaining }} 秒</p>
<button @click="callApi" class="btn secondary">调用受保护 API (WeatherForecast)</button>
<button @click="logout" class="btn danger">登出</button>
<div v-if="apiResponse" class="api-response">
<h3>API 响应:</h3>
<pre>{{ apiResponse }}</pre>
</div>
<div v-if="apiError" class="api-error">
<h3>API 错误:</h3>
<pre>{{ apiError }}</pre>
</div>
</div>
</div>
</template>
<script>
import userManager from '@/auth/oidc'; // 引入 oidc manager
import axios from 'axios';
export default {
name: 'Home-Page',
data () {
return {
isAuthenticated: false,
userProfile: {},
apiResponse: null,
apiError: null,
tokenExpirationRemaining: 0,
tokenRefreshInterval: null // 用于刷新令牌倒计时的计时器
};
},
async created () {
// 在组件创建时检查用户状态
await this.checkUserStatus();
// 每秒更新令牌有效期倒计时
this.tokenRefreshInterval = setInterval(this.updateTokenExpiration, 1000);
},
beforeDestroy () {
// 组件销毁前清除计时器
if (this.tokenRefreshInterval) {
clearInterval(this.tokenRefreshInterval);
}
},
methods: {
async checkUserStatus () {
const user = await userManager.getUser();
if (user && !user.expired) { // 检查用户是否存在且令牌未过期
this.isAuthenticated = true;
this.userProfile = user.profile;
this.updateTokenExpiration(); // 立即更新一次
console.log("Logged in user:", JSON.stringify(user));
} else {
this.isAuthenticated = false;
this.userProfile = {};
console.log("No user logged in or token expired.");
}
},
async updateTokenExpiration() { // 修改为 async 方法,以便获取最新的 user 对象
if (this.isAuthenticated) {
const user = await userManager.getUser(); // 获取最新的 user 对象
if (user && typeof user.expires_at === 'number') {
const now = Math.floor(Date.now() / 1000); // 当前时间(秒)
// 计算剩余时间
this.tokenExpirationRemaining = Math.max(0, user.expires_at - now);
} else {
this.tokenExpirationRemaining = 0;
}
} else {
this.tokenExpirationRemaining = 0;
}
},
login () {
// 重定向到认证中心进行登录
userManager.signinRedirect();
},
async logout () {
try {
await userManager.signoutRedirect(); // 重定向到认证中心进行登出
// 登出后 oidc-client 会清除本地存储的用户数据
this.isAuthenticated = false;
this.userProfile = {};
this.apiResponse = null;
this.apiError = null;
this.tokenExpirationRemaining = 0;
console.log("User logged out.");
} catch (error) {
console.error("Logout error:", error);
this.apiError = `Logout Error: ${error.message}`;
}
},
async callApi () {
this.apiResponse = null;
this.apiError = null;
try {
const currentUser = await userManager.getUser();
if (currentUser && currentUser.access_token) {
console.log("Calling API with token:", currentUser.access_token);
const response = await axios.get('http://localhost:5004/WeatherForecast', { // **您的 MySecureWebApi 地址**
headers: {
Authorization: `Bearer ${currentUser.access_token}`
}
});
this.apiResponse = JSON.stringify(response.data, null, 2);
} else {
this.apiResponse = '未认证,无法调用 API。请先登录。';
}
} catch (error) {
console.error('API call failed:', error.response ? error.response.data : error.message);
this.apiError = `API Error: ${error.response ? error.response.status + ': ' + JSON.stringify(error.response.data) : error.message}`;
}
}
}
};
</script>
<style scoped>
#app-container {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
border: 1px solid #eee;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-color: #fff;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.auth-section {
text-align: center;
margin-bottom: 30px;
}
.btn {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
}
.btn.primary {
background-color: #007bff;
color: white;
}
.btn.primary:hover {
background-color: #0056b3;
}
.btn.secondary {
background-color: #6c757d;
color: white;
}
.btn.secondary:hover {
background-color: #545b62;
}
.btn.danger {
background-color: #dc3545;
color: white;
}
.btn.danger:hover {
background-color: #bd2130;
}
.api-response,
.api-error {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
background-color: #f8f9fa;
border: 1px solid #ddd;
overflow-x: auto;
}
.api-error {
background-color: #fdd;
border-color: #fbc;
color: #a00;
}
pre {
white-space: pre-wrap;
word-break: break-all;
}
</style>
3️⃣:创建回调界面(views/Home.vue)
<!--
Copyright (c) 2025 zdb
This software is released under the MIT License.
https://opensource.org/licenses/MIT
-->
<template>
<div class="callback-container">
<h2>正在处理认证...</h2>
<p>请稍候,您将被重定向。</p>
<div class="spinner"></div>
</div>
</template>
<script>
import userManager from '@/auth/oidc';
export default {
name: 'Callback-Page',
async created() {
try {
console.log('Callback page: Processing signinRedirectCallback...');
await userManager.signinRedirectCallback(); // 处理重定向回调,获取令牌
console.log('Callback successful. Redirecting to home.');
this.$router.push('/'); // 认证成功后,重定向回首页
} catch (error) {
console.error('Authentication callback error:', error);
// 处理错误,例如显示错误消息给用户
this.$router.push({ name: 'Error', query: { message: error.message } }); // 跳转到错误页面并传递错误信息
}
}
};
</script>
<style scoped>
.callback-container {
text-align: center;
padding: 50px;
font-family: Arial, sans-serif;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: #007bff;
animation: spin 1s ease infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
4️⃣:创建路由(src/router/index.js)
// Copyright (c) 2025 zdb
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// src/router/index.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from '../views/Home.vue';
import Callback from '../views/Callback.vue';
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/callback', // **必须与 Orchard Core 中配置的 Redirect URI 路径部分匹配**
name: 'Callback',
component: Callback
},
// 你可以添加更多路由
{
path: '/error', // 一个简单的错误页面,用于回调失败时
name: 'Error',
template: '<div><p>An error occurred during authentication. Please try again.</p></div>'
}
];
const router = new VueRouter({
mode: 'history', // **推荐使用 history 模式,如果服务器不支持,会回退到 hash 模式**
base: process.env.BASE_URL,
routes
});
export default router;
import Vue from 'vue'
import App from './App.vue'
import router from './router'; // 1. 确保正确导入您的 router 实例
Vue.config.productionTip = false
new Vue({
router, // 2. 这里是关键!将 router 实例注入到 Vue 根实例中
render: h => h(App),
}).$mount('#app')
步骤三:MySecureWebApi 项目配置(Program.cs)
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using System.Linq; // 确保有这个 using
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// 强制设置控制台日志的最低级别为 Debug
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Debug);
builder.Logging.SetMinimumLevel(LogLevel.Debug);
builder.Services.AddCors(options =>
{
options.AddPolicy(name: "MyAllowSpecificOrigins",
builder =>
{
builder.WithOrigins("http://localhost:8080")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ****** 添加 JWT Bearer 认证配置 ******
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "http://localhost:5233/";
options.RequireHttpsMetadata = false;
options.Audience = "api1";
// *** 禁用默认的 Claims 映射 ***
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudience = "api1",
ValidateIssuer = true,
ValidIssuer = "http://localhost:5233/",
ValidateLifetime = true, // 确保这里是 true
ClockSkew = TimeSpan.Zero, // 严格校验令牌过期时间
// ****** 临时添加:自定义令牌生命周期验证 ******
// 这会强制 Access Token 在签发后 X 秒内过期
LifetimeValidator = (notBefore, expires, securityToken, validationParameters) =>
{
// 定义一个短的有效时间,例如 60 秒 (1分钟)
var shortLifetime = TimeSpan.FromSeconds(10);
// 计算令牌的实际生命周期
if (expires.HasValue && notBefore.HasValue)
{
// 令牌的实际有效时间
var actualLifetime = expires.Value - notBefore.Value;
// 如果实际生命周期大于我们想要的短生命周期,则按短生命周期计算过期
// 这将导致令牌在短生命周期后被认为是过期的
if (actualLifetime > shortLifetime)
{
// 强制令牌在 notBefore + shortLifetime 时过期
return DateTime.UtcNow <= notBefore.Value.Add(shortLifetime);
}
}
// 否则,使用原始的过期时间进行验证
return expires.HasValue && expires.Value > DateTime.UtcNow;
}
};
// ****** 移除或注释掉 OnTokenValidated 事件中的临时响应代码 ******
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtBearerHandler>>();
logger.LogInformation("Token validated successfully. Claims in principal:");
foreach (var claim in context.Principal.Claims)
{
logger.LogInformation($"- Type: {claim.Type}, Value: {claim.Value}");
}
var scopeClaims = context.Principal.Claims.Where(c => c.Type == "scope").ToList();
if (scopeClaims.Any())
{
logger.LogInformation($"Found 'scope' claims: {string.Join(", ", scopeClaims.Select(c => c.Value))}");
if (scopeClaims.Any(c => c.Value.Contains("api1")))
{
logger.LogInformation("'api1' scope found in claims. Authorization policy should now succeed.");
}
else
{
logger.LogWarning("'api1' scope NOT found among claims, even though 'scope' claims exist. Policy might still fail.");
}
}
else
{
logger.LogWarning("No 'scope' claims found for the authenticated user. Policy will fail.");
}
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtBearerHandler>>();
logger.LogError(context.Exception, "Authentication failed during token validation. Exception: {Message}", context.Exception.Message);
return Task.CompletedTask;
},
OnForbidden = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtBearerHandler>>();
logger.LogWarning("Access forbidden after authentication due to authorization policy failure.");
return Task.CompletedTask;
}
};
});
// ****** 添加授权配置 ******
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
// 修改为 RequireAssertion
policy.RequireAssertion(context =>
{
var scopeClaims = context.User.FindAll("scope");
return scopeClaims.Any(c => c.Value.Contains("api1"));
});
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("MyAllowSpecificOrigins");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
补充:增加刷新token操作
实际测试中:后台这里配置不配置貌似没啥作用,只要前端加了offline_access 之后就会返回刷新token字段
前端代码部分:scope: 'openid profile api1 offline_access', // 请求的权限范围
vue2.0接入 认证中心(Orchard Core CMS)
https://zdbxll.cn/archives/1750053495902