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_tokenid_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
作者
Administrator
发布于
更新于
许可