perf: 支持微信扫码登录

This commit is contained in:
xiaojunnuo
2025-11-30 01:13:55 +08:00
parent 0adcc6a8d1
commit 73325aaefb
14 changed files with 237 additions and 41 deletions

View File

@@ -766,7 +766,10 @@ export default {
oauthProviders: "OAuth2 Login Providers",
oauthType: "OAuth2 Login Type",
oauthConfig: "OAuth2 Login Config",
oauthProviderSelectorPlaceholder: "Please select OAuth2 login provider",
oauthProviderSelectorPlaceholder: "Not Configured",
oauthCallback: "Callback URL",
oauthCallbackHelper: "Copy this URL to the callback address of the OAuth2 login provider",
oauthCallbackCopy: "Copy Callback URL",
},
},
modal: {

View File

@@ -767,7 +767,10 @@ export default {
oauthProviders: "第三方登录提供商",
oauthType: "第三方登录类型",
oauthConfig: "第三方登录配置",
oauthProviderSelectorPlaceholder: "请选择第三方登录提供商",
oauthProviderSelectorPlaceholder: "未配置",
oauthCallback: "回调地址",
oauthCallbackHelper: "复制回调地址,配置到对应提供商的回调地址中",
oauthCallbackCopy: "复制回调地址",
},
},
modal: {

View File

@@ -1,6 +1,6 @@
<template>
<div class="addon-selector">
<div class="flex-o w-100">
<div class="flex-o w-100 inner">
<!-- <fs-dict-select class="flex-1" :value="modelValue" :dict="optionsDictRef" :disabled="disabled" :render-label="renderLabel" :slots="selectSlots" :allow-clear="true" v-bind="select" @update:value="onChange" />-->
<span v-if="modelValue" class="mr-5 cd-flex-inline">
<a-tag class="mr-5" color="green">{{ target?.name || modelValue }}</a-tag>
@@ -175,5 +175,9 @@ async function doRefresh() {
<style lang="less">
.addon-selector {
width: 100%;
.inner {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -121,7 +121,14 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any, a
},
editForm: {
component: {
disabled: false,
disabled: true,
},
},
addForm: {
component: {
disabled: compute(({ form }) => {
return form.type ? true : false;
}),
},
},
form: {

View File

@@ -40,6 +40,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
editRequest,
delRequest,
},
search: {
initialForm: {
addonType: addonType,
type: type,
},
},
form: {
labelCol: {
//固定label宽度

View File

@@ -2,13 +2,14 @@ import { request } from "/src/api/service";
const apiPrefix = "/oauth";
export async function OauthLogin(type: string, forType?: string) {
export async function OauthLogin(type: string, forType?: string, from?: string) {
return await request({
url: apiPrefix + `/login`,
method: "post",
data: {
type,
forType: forType || "login",
from: from || "web",
},
});
}

View File

@@ -3,11 +3,13 @@
<div class="oauth-title">
<div class="oauth-title-text">其他方式登录</div>
</div>
<div v-for="item in oauthList" :key="item.type">
<div class="oauth-icon-button pointer" @click="goOauthLogin(item.name)">
<div><fs-icon :icon="item.icon" class="text-blue-600 text-40" /></div>
<div>{{ item.title }}</div>
</div>
<div class="flex justify-center items-center gap-4">
<template v-for="item in oauthProviderList" :key="item.type">
<div v-if="item.addonId" class="oauth-icon-button pointer" @click="goOauthLogin(item.name)">
<div><fs-icon :icon="item.icon" class="text-blue-600 text-40" /></div>
<div>{{ item.addonTitle || item.title }}</div>
</div>
</template>
</div>
</div>
</template>
@@ -15,15 +17,16 @@
import { onMounted, ref } from "vue";
import * as api from "./api";
const oauthList = ref([]);
const oauthProviderList = ref([]);
onMounted(async () => {
oauthList.value = await api.GetOauthProviders();
oauthProviderList.value = await api.GetOauthProviders();
});
async function goOauthLogin(type: string) {
//获取第三方登录URL
const res = await api.OauthLogin(type);
const from = "web";
const res = await api.OauthLogin(type, from);
const loginUrl = res.loginUrl;
window.location.href = loginUrl;
}

View File

@@ -114,7 +114,7 @@ export async function GetSmsTypeDefine(type: string) {
export async function GetOauthProviders() {
return await request({
url: apiPrefix + "/oauth/providers",
url: "/oauth/providers",
method: "post",
});
}

View File

@@ -67,8 +67,9 @@
<table class="w-full table-auto border-collapse border border-gray-400">
<thead>
<tr>
<th class="border border-gray-300 px-4 py-2 w-1/2">{{ t("certd.sys.setting.oauthType") }}</th>
<th class="border border-gray-300 px-4 py-2 w-1/2">{{ t("certd.sys.setting.oauthConfig") }}</th>
<th class="border border-gray-300 px-4 py-2 w-1/3">{{ t("certd.sys.setting.oauthType") }}</th>
<th class="border border-gray-300 px-4 py-2 w-1/3">{{ t("certd.sys.setting.oauthCallback") }}</th>
<th class="border border-gray-300 px-4 py-2 w-1/3">{{ t("certd.sys.setting.oauthConfig") }}</th>
</tr>
</thead>
<tbody>
@@ -79,6 +80,11 @@
{{ item.title }}
</div>
</td>
<td class="border border-gray-300 px-4 py-2 overflow-ellipsis" :title="t('certd.sys.setting.oauthCallbackHelper')">
<fs-copyable :model-value="buildCallbackUrl(item.name)">
{{ t("certd.sys.setting.oauthCallbackCopy") }}
</fs-copyable>
</td>
<td class="border border-gray-300 px-4 py-2">
<AddonSelector v-model:model-value="item.addonId" addon-type="oauth" from="sys" :type="item.name" :placeholder="t('certd.sys.setting.oauthProviderSelectorPlaceholder')" />
</td>
@@ -96,14 +102,14 @@
</template>
<script setup lang="tsx">
import { computed, reactive, ref, Ref } from "vue";
import { GetSmsTypeDefine, SysSettings } from "/@/views/sys/settings/api";
import * as api from "/@/views/sys/settings/api";
import { merge } from "lodash-es";
import { useSettingStore } from "/@/store/settings";
import { notification } from "ant-design-vue";
import { useI18n } from "/src/locales";
import { merge } from "lodash-es";
import { reactive, ref, Ref } from "vue";
import AddonSelector from "../../../certd/addon/addon-selector/index.vue";
import { useSettingStore } from "/@/store/settings";
import * as api from "/@/views/sys/settings/api";
import { SysSettings } from "/@/views/sys/settings/api";
import { useI18n } from "/src/locales";
const { t } = useI18n();
defineOptions({
@@ -192,15 +198,7 @@ async function loadTypeDefine(type: string) {
const oauthProviders = ref([]);
async function loadOauthProviders() {
let list: any = await api.GetOauthProviders();
oauthProviders.value = list;
for (const item of list) {
const type = item.name;
const provider = formState.public.oauthProviders?.[type];
if (provider) {
item.addonId = provider.addonId;
}
}
oauthProviders.value = await api.GetOauthProviders();
}
function fillOauthProviders(form: any) {
@@ -251,8 +249,19 @@ const onFinish = async (form: any) => {
saveLoading.value = false;
}
};
function buildCallbackUrl(type: string) {
return `${window.location.origin}/api/oauth/callback/${type}`;
}
</script>
<style lang="less">
.sys-settings-site {
.sys-settings-register {
width: 1000px !important;
.addon-selector {
.inner {
justify-content: space-between;
}
}
}
</style>

View File

@@ -1,4 +1,4 @@
import { addonRegistry, BaseController, Constants, SysInstallInfo, SysSettingsService } from "@certd/lib-server";
import { addonRegistry, AddonService, BaseController, Constants, SysInstallInfo, SysSettingsService } from "@certd/lib-server";
import { ALL, Body, Controller, Get, Inject, Param, Post, Provide, Query } from "@midwayjs/core";
import { AddonGetterService } from "../../../modules/pipeline/service/addon-getter-service.js";
import { IOauthProvider } from "../../../plugins/plugin-oauth/api.js";
@@ -31,6 +31,9 @@ export class ConnectController extends BaseController {
@Inject()
oauthBoundService: OauthBoundService;
@Inject()
addonService: AddonService;
private async getOauthProvider(type: string) {
@@ -51,14 +54,14 @@ export class ConnectController extends BaseController {
}
@Post('/login', { summary: Constants.per.guest })
public async login(@Body(ALL) body: { type: string, forType?:string }) {
public async login(@Body(ALL) body: { type: string, forType?:string ,from?:string }) {
const addon = await this.getOauthProvider(body.type);
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
const bindUrl = installInfo?.bindUrl || "";
//构造登录url
const redirectUrl = `${bindUrl}api/oauth/callback/${body.type}`;
const { loginUrl, ticketValue } = await addon.buildLoginUrl({ redirectUri: redirectUrl, forType: body.forType });
const { loginUrl, ticketValue } = await addon.buildLoginUrl({ redirectUri: redirectUrl, forType: body.forType ,from: body.from || "web" });
const ticket = this.codeService.setValidationValue(ticketValue)
this.ctx.cookies.set("oauth_ticket", ticket, {
httpOnly: true,
@@ -217,10 +220,32 @@ export class ConnectController extends BaseController {
return this.ok(bounds);
}
@Post('/providers', { summary: Constants.per.guest })
@Post('/providers', { summary: Constants.per.guest })
public async providers() {
const list = addonRegistry.getDefineList("oauth");
const defineList = addonRegistry.getDefineList("oauth");
const publicSetting = await this.sysSettingsService.getPublicSettings();
const oauthProviders = publicSetting.oauthProviders || {};
const list = [];
for (const item of defineList) {
const type = item.name
const conf = oauthProviders[type];
const provider:any = {
...item,
}
delete provider.input
if (conf && conf.addonId) {
const addonEntity = await this.addonService.info(conf.addonId);
if (addonEntity) {
provider.addonId = conf.addonId;
provider.addonTitle = addonEntity.name;
}
}
list.push(provider);
}
return this.ok(list);
}
}

View File

@@ -37,7 +37,13 @@ export type LoginUrlReply = {
ticketValue: any;
}
export type BuildLoginUrlReq = {
redirectUri: string;
forType?: string;
from?:string;
}
export interface IOauthProvider {
buildLoginUrl: (params: { redirectUri: string, forType?: string }) => Promise<LoginUrlReply>;
buildLoginUrl: (params: BuildLoginUrlReq) => Promise<LoginUrlReply>;
onCallback: (params: OnCallbackReq) => Promise<OauthToken>;
}

View File

@@ -1,2 +1,3 @@
export * from './api.js'
export * from './oidc/plugin-oidc.js'
export * from './wx/plugin-wx.js'

View File

@@ -1,5 +1,5 @@
import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server";
import { IOauthProvider, OnCallbackReq } from "../api.js";
import { BuildLoginUrlReq, IOauthProvider, OnCallbackReq } from "../api.js";
@IsAddon({
addonType: "oauth",
@@ -56,7 +56,7 @@ export class OidcOauthProvider extends BaseAddon implements IOauthProvider {
}
}
async buildLoginUrl(params: { redirectUri: string, forType?: string }) {
async buildLoginUrl(params: BuildLoginUrlReq) {
const { config, client } = await this.getClient()
let redirect_uri = new URL(params.redirectUri)

View File

@@ -0,0 +1,128 @@
import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server";
import { BuildLoginUrlReq, IOauthProvider, OnCallbackReq } from "../api.js";
@IsAddon({
addonType: "oauth",
name: 'wx',
title: '微信登录',
desc: '微信网站应用登录',
icon: "mdi:wechat",
showTest: false,
})
export class WxOauthProvider extends BaseAddon implements IOauthProvider {
@AddonInput({
title: "AppId",
required: true,
helper: "在[微信开放平台](https://open.weixin.qq.com/cgi-bin/index)注册网站应用后获取",
})
appId = "";
@AddonInput({
title: "AppSecretKey",
component: {
placeholder: "AppSecretKey",
},
required: true,
})
appSecretKey = "";
wxAccessToken?: { access_token: string, expires_at: number }
async buildLoginUrl(params: BuildLoginUrlReq) {
const from = params.from || "web";
const appId = this.appId;
const redirect_uri = encodeURIComponent(params.redirectUri);
let state: any = {
forType: params.forType,
from
}
state = this.ctx.utils.hash.base64(JSON.stringify(state))
let scope = "snsapi_userinfo";
let loginUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirect_uri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`
if (from === "web") {
scope = "snsapi_login";
loginUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=${appId}&redirect_uri=${redirect_uri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`
}
return {
loginUrl,
ticketValue: {
state,
},
};
}
// async getWxAccessToken() {
// if (this.wxAccessToken && this.wxAccessToken.expires_at > Date.now()) {
// return this.wxAccessToken
// }
// const res = await this.http.request({
// url: "https://api.weixin.qq.com/cgi-bin/token",
// method: "GET",
// params: {
// appid: this.appId,
// secret: this.appSecretKey,
// grant_type: "client_credential",
// },
// })
// this.checkRet(res)
// this.wxAccessToken = {
// access_token: res.access_token,
// expires_at: Date.now() + res.expires_in * 1000,
// }
// return this.wxAccessToken
// }
checkRet(res: any) {
if (res.errcode) {
throw new Error(res.errmsg)
}
}
async onCallback(req: OnCallbackReq) {
// GET https://api.weixin.qq.com/sns/oauth2/access_token?appid=wx520c15f417810387&secret=SECRET&code=CODE&grant_type=authorization_code
const res = await this.http.request({
url: "https://api.weixin.qq.com/sns/oauth2/access_token",
method: "GET",
params: {
appid: this.appId,
secret: this.appSecretKey,
code: req.code,
grant_type: "authorization_code",
},
})
this.checkRet(res)
const accessToken = res.access_token
// GET https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
const userInfoRes = await this.http.request({
url: "https://api.weixin.qq.com/sns/userinfo",
method: "GET",
params: {
access_token:accessToken,
openid: res.openid,
lang: "zh_CN",
},
})
this.checkRet(userInfoRes)
return {
token: {
accessToken: res.access_token,
refreshToken: res.refresh_token,
expiresIn: res.expires_in,
},
userInfo: {
openId: res.unionid || res.openid,
nickName: userInfoRes.nickname || "",
avatar: userInfoRes.headimgurl,
},
}
};
}