perf: 授权配置支持加密

原本已经添加的授权配置,再次编辑保存即变成加密配置
pull/148/head
xiaojunnuo 2024-08-27 13:46:19 +08:00
parent d6bb9f6af4
commit 42a56b581d
35 changed files with 338 additions and 80 deletions

View File

@ -255,9 +255,8 @@ module.exports = async (client, userOpts) => {
return await client.getCertificate(finalized, opts.preferredChain);
}
catch (e) {
log('证书申请失败');
log(e);
throw new Error(`证书申请失败:${e.message}`);
log(`证书申请失败${e.message}`);
throw e;
}
finally {
log(`清理challenge痕迹length:${clearTasks.length}`);

View File

@ -290,7 +290,6 @@ exports.readCsrDomains = (csrPem) => {
if (Buffer.isBuffer(csrPem)) {
csrPem = csrPem.toString();
}
const dec = x509.PemConverter.decodeFirst(csrPem);
const csr = new x509.Pkcs10CertificateRequest(dec);
return parseDomains(csr);

View File

@ -4,6 +4,7 @@ import { FormItemProps } from "../dt/index.js";
export type AccessInputDefine = FormItemProps & {
title: string;
required?: boolean;
encrypt?: boolean;
};
export type AccessDefine = Registrable & {
input?: {

View File

@ -13,6 +13,7 @@ export class EabAccess {
},
helper: "EAB KID",
required: true,
encrypt: true,
})
kid = "";
@AccessInput({
@ -22,6 +23,7 @@ export class EabAccess {
},
helper: "EAB HMAC Key",
required: true,
encrypt: true,
})
hmacKey = "";
}

View File

@ -7,7 +7,7 @@ import { IContext } from "@certd/pipeline";
import { IDnsProvider } from "../../dns-provider/index.js";
import psl from "psl";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import { utils } from "@certd/pipeline";
export type CertInfo = {
crt: string;
key: string;
@ -90,6 +90,13 @@ export class AcmeService {
}
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
const client = new acme.Client({
directoryUrl: directoryUrl,
@ -295,4 +302,19 @@ export class AcmeService {
altNames,
};
}
private async testDirectory(directoryUrl: string) {
try {
await utils.http({
url: directoryUrl,
method: "GET",
timeout: 5000,
});
} catch (e) {
this.logger.error(`${directoryUrl},测试访问失败`, e);
return false;
}
this.logger.info(`${directoryUrl},测试访问成功`);
return true;
}
}

View File

@ -80,7 +80,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
name: "pi-dns-provider-selector",
},
required: true,
helper: "请选择dns解析提供商",
helper:
"请选择dns解析提供商您的域名是在哪里注册的或者域名的dns解析服务器属于哪个平台\n如果这里没有您的dns解析提供商您可以将域名解析服务器设置成上面的任意一个提供商",
})
dnsProviderType!: string;
@ -108,7 +109,6 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
name: "a-switch",
vModel: "checked",
},
maybeNeed: true,
helper: "如果acme-v02.api.letsencrypt.org或dv.acme-v02.api.pki.goog被墙无法访问请尝试开启此选项",
})
useProxy = false;

View File

@ -30,6 +30,7 @@
"@fast-crud/ui-interface": "^1.21.2",
"@iconify/vue": "^4.1.1",
"@soerenmartius/vue3-clipboard": "^0.1.2",
"@vue-js-cron/light": "^4.0.5",
"ant-design-vue": "^4.1.2",
"axios": "^1.7.2",
"axios-mock-adapter": "^1.22.0",

View File

@ -0,0 +1,80 @@
<template>
<div class="cron-editor">
<div class="flex-o">
<cron-light
:disabled="disabled"
:readonly="readonly"
:period="period"
class="flex-o cron-ant"
locale="zh-CN"
format="quartz"
:model-value="modelValue"
@update:model-value="onUpdate"
@error="onError"
/>
</div>
<div class="mt-5">
<a-input :disabled="true" :readonly="readonly" :value="modelValue" @change="onChange"></a-input>
</div>
<div class="fs-helper">{{ errorMessage }}</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const props = defineProps<{
modelValue?: string;
disabled?: boolean;
readonly?: boolean;
}>();
const period = ref<string>("day");
const emit = defineEmits<{
"update:modelValue": any;
}>();
const errorMessage = ref<string | null>(null);
const onUpdate = (value: string) => {
if (value === props.modelValue) {
return;
}
emit("update:modelValue", value);
errorMessage.value = undefined;
};
const onPeriod = (value: string) => {
period.value = value;
};
const onChange = (e: any) => {
const value = e.target.value;
onUpdate(value);
};
const onError = (error: any) => {
errorMessage.value = error;
};
</script>
<style lang="less">
.cron-editor {
.cron-ant {
flex-wrap: wrap;
&* > {
margin-bottom: 2px;
display: flex;
align-items: center;
}
.vcron-select-list {
min-width: 56px;
}
.vcron-select-input {
min-height: 22px;
}
.vcron-select-container {
display: flex;
align-items: center;
}
}
}
</style>

View File

@ -5,6 +5,9 @@ import PiOutputSelector from "../views/certd/pipeline/pipeline/component/output-
import PiEditable from "./editable.vue";
import VipButton from "./vip-button/index.vue";
import { CheckCircleOutlined, InfoCircleOutlined, UndoOutlined } from "@ant-design/icons-vue";
import CronEditor from "./cron-editor/index.vue";
import { CronLight } from "@vue-js-cron/light";
import "@vue-js-cron/light/dist/light.css";
export default {
install(app: any) {
app.component("PiContainer", PiContainer);
@ -13,6 +16,8 @@ export default {
app.component("PiOutputSelector", PiOutputSelector);
app.component("PiDnsProviderSelector", PiDnsProviderSelector);
app.component("VipButton", VipButton);
app.component("CronLight", CronLight);
app.component("CronEditor", CronEditor);
app.component("CheckCircleOutlined", CheckCircleOutlined);
app.component("InfoCircleOutlined", InfoCircleOutlined);

View File

@ -64,7 +64,9 @@ h1, h2, h3, h4, h5, h6 {
flex: 1;
}
.mb-2{
margin-bottom:2px;
}
.ml-5{
margin-left:5px;
}
@ -84,6 +86,9 @@ h1, h2, h3, h4, h5, h6 {
.mr-15{
margin-right: 15px;
}
.mt-5{
margin-top:5px;
}
.mt-10{
margin-top:10px;
}

View File

@ -39,7 +39,8 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
form: {
wrapper: {
width: "1150px",
saveRemind: false
saveRemind: false,
title: "创建证书申请流水线"
}
},
columns: {
@ -73,6 +74,8 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
type: "text",
form: {
component: {
name: "cron-editor",
vModel: "modelValue",
placeholder: "0 0 4 * * *"
},
helper: "请输入cron表达式, 例如0 0 4 * * *每天凌晨4点触发",

View File

@ -112,7 +112,7 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
},
addCertd: {
order: 1,
text: "添加证书流水线",
text: "创建证书流水线",
type: "primary",
click() {
addCertdPipeline();

View File

@ -15,13 +15,7 @@
</template>
<template v-if="currentTrigger">
<pi-container>
<a-form
ref="triggerFormRef"
class="trigger-form"
:model="currentTrigger"
:label-col="labelCol"
:wrapper-col="wrapperCol"
>
<a-form ref="triggerFormRef" class="trigger-form" :model="currentTrigger" :label-col="labelCol" :wrapper-col="wrapperCol">
<fs-form-item
v-model="currentTrigger.title"
:item="{
@ -59,8 +53,8 @@
key: 'props.cron',
component: {
disabled: !editMode,
name: 'a-input',
vModel: 'value'
name: 'cron-editor',
vModel: 'modelValue'
},
helper: 'cron表达式例如 0 0 3 * * * 表示每天凌晨3点触发',
rules: [{ required: true, message: '此项必填' }]

View File

@ -0,0 +1 @@
alter table cd_access add "encrypt_setting" text;

View File

@ -0,0 +1,2 @@
alter table cd_access add COLUMN "encrypt_setting" text;

View File

@ -12,13 +12,11 @@ import { PipelineEntity } from '../modules/pipeline/entity/pipeline.js';
//import { logger } from '../utils/logger';
// load .env file in process.cwd
import { mergeConfig } from './loader.js';
import { Keys } from './keys.js';
const env = process.env.NODE_ENV || 'development';
const keys = Keys.load();
const development = {
keys: keys.cookieKeys,
keys: 'certd',
koa: {
port: 7001,
},
@ -78,7 +76,6 @@ const development = {
auth: {
jwt: {
secret: keys.jwtKey,
expire: 7 * 24 * 60 * 60, //单位秒
},
},

View File

@ -1,31 +0,0 @@
import fs from 'fs';
import yaml from 'js-yaml';
import * as _ from 'lodash-es';
import { nanoid } from 'nanoid';
import path from 'path';
const KEYS_FILE = './data/keys.yaml';
export class Keys {
jwtKey: string = nanoid();
cookieKeys: string[] = [nanoid()];
static load(): Keys {
const keys = new Keys();
if (fs.existsSync(KEYS_FILE)) {
const content = fs.readFileSync(KEYS_FILE, 'utf8');
const json = yaml.load(content);
_.merge(keys, json);
}
keys.save();
return keys;
}
save() {
const parent = path.dirname(KEYS_FILE);
if (!fs.existsSync(parent)) {
fs.mkdirSync(parent, {
recursive: true,
});
}
fs.writeFileSync(KEYS_FILE, yaml.dump(this));
}
}

View File

@ -1,21 +1,31 @@
import { Config, Inject, MidwayWebRouterService, Provide } from '@midwayjs/core';
import { Init, Inject, MidwayWebRouterService, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { IMidwayKoaContext, IWebMiddleware, NextFunction } from '@midwayjs/koa';
import jwt from 'jsonwebtoken';
import { Constants } from '../basic/constants.js';
import { logger } from '../utils/logger.js';
import { AuthService } from '../modules/authority/service/auth-service.js';
import { SysSettingsService } from '../modules/system/service/sys-settings-service.js';
import { SysPrivateSettings } from '../modules/system/service/models.js';
/**
*
*/
@Provide()
@Scope(ScopeEnum.Singleton)
export class AuthorityMiddleware implements IWebMiddleware {
@Config('auth.jwt.secret')
private secret: string;
@Inject()
webRouterService: MidwayWebRouterService;
@Inject()
authService: AuthService;
@Inject()
sysSettingsService: SysSettingsService;
secret: string;
@Init()
async init() {
const setting: SysPrivateSettings = await this.sysSettingsService.getSetting(SysPrivateSettings);
this.secret = setting.jwtKey;
}
resolve() {
return async (ctx: IMidwayKoaContext, next: NextFunction) => {

View File

@ -3,9 +3,9 @@ import { logger } from '../../utils/logger.js';
import { UserService } from '../authority/service/user-service.js';
import { SysSettingsService } from '../system/service/sys-settings-service.js';
import { nanoid } from 'nanoid';
import { SysInstallInfo, SysLicenseInfo } from '../system/service/models.js';
import { SysInstallInfo, SysLicenseInfo, SysPrivateSettings } from '../system/service/models.js';
import { verify } from '@certd/pipeline';
import crypto from 'crypto';
export type InstallInfo = {
installTime: number;
instanceId?: string;
@ -23,6 +23,7 @@ export class AutoInitSite {
@Init()
async init() {
logger.info('初始化站点开始');
//安装信息
const installInfo: SysInstallInfo = await this.sysSettingsService.getSetting(SysInstallInfo);
if (!installInfo.siteId) {
installInfo.siteId = nanoid();
@ -33,6 +34,19 @@ export class AutoInitSite {
await this.sysSettingsService.saveSetting(installInfo);
}
//private信息
const privateInfo = await this.sysSettingsService.getSetting<SysPrivateSettings>(SysPrivateSettings);
if (!privateInfo.jwtKey) {
privateInfo.jwtKey = nanoid();
await this.sysSettingsService.saveSetting(privateInfo);
}
if (!privateInfo.encryptSecret) {
const secretKey = crypto.randomBytes(32);
privateInfo.encryptSecret = secretKey.toString('base64');
await this.sysSettingsService.saveSetting(privateInfo);
}
// 授权许可
const licenseInfo: SysLicenseInfo = await this.sysSettingsService.getSetting(SysLicenseInfo);
const req = {

View File

@ -4,6 +4,8 @@ import jwt from 'jsonwebtoken';
import { CommonException } from '../../../basic/exception/common-exception.js';
import { RoleService } from '../../authority/service/role-service.js';
import { UserEntity } from '../../authority/entity/user.js';
import { SysSettingsService } from '../../system/service/sys-settings-service.js';
import { SysPrivateSettings } from '../../system/service/models.js';
/**
*
@ -17,6 +19,9 @@ export class LoginService {
@Config('auth.jwt')
private jwt: any;
@Inject()
sysSettingsService: SysSettingsService;
/**
* login
*/
@ -47,7 +52,11 @@ export class LoginService {
roles: roleIds,
};
const expire = this.jwt.expire;
const token = jwt.sign(tokenInfo, this.jwt.secret, {
const setting = await this.sysSettingsService.getSetting<SysPrivateSettings>(SysPrivateSettings);
const jwtSecret = setting.jwtKey;
const token = jwt.sign(tokenInfo, jwtSecret, {
expiresIn: expire,
});

View File

@ -1,12 +1,4 @@
import {
ALL,
Body,
Controller,
Inject,
Post,
Provide,
Query,
} from '@midwayjs/core';
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core';
import { CrudController } from '../../../basic/crud-controller.js';
import { AccessService } from '../service/access-service.js';
import { Constants } from '../../../basic/constants.js';
@ -28,7 +20,7 @@ export class AccessController extends CrudController<AccessService> {
async page(@Body(ALL) body) {
body.query = body.query ?? {};
body.query.userId = this.ctx.user.id;
return super.page(body);
return await super.page(body);
}
@Post('/list', { summary: Constants.per.authOnly })

View File

@ -15,9 +15,12 @@ export class AccessEntity {
@Column({ comment: '类型', length: 100 })
type: string;
@Column({ name: 'setting', comment: '设置', length: 1024, nullable: true })
@Column({ name: 'setting', comment: '设置', length: 10240, nullable: true })
setting: string;
@Column({ name: 'encrypt_setting', comment: '已加密设置', length: 10240, nullable: true })
encryptSetting: string;
@Column({
name: 'create_time',
comment: '创建时间',

View File

@ -1,33 +1,127 @@
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { BaseService } from '../../../basic/base-service.js';
import { AccessEntity } from '../entity/access.js';
import { accessRegistry, IAccessService } from '@certd/pipeline';
import { AccessDefine, accessRegistry, IAccessService } from '@certd/pipeline';
import { EncryptService } from './encrypt-service.js';
import { ValidateException } from '../../../basic/exception/validation-exception.js';
/**
*
*/
@Provide()
@Scope(ScopeEnum.Singleton)
export class AccessService
extends BaseService<AccessEntity>
implements IAccessService
{
export class AccessService extends BaseService<AccessEntity> implements IAccessService {
@InjectEntityModel(AccessEntity)
repository: Repository<AccessEntity>;
@Inject()
encryptService: EncryptService;
getRepository() {
return this.repository;
}
async page(query, page = { offset: 0, limit: 20 }, order, buildQuery) {
const res = await super.page(query, page, order, buildQuery);
res.records = res.records.map(item => {
delete item.encryptSetting;
return item;
});
return res;
}
async add(param) {
this.encryptSetting(param, null);
return await super.add(param);
}
encryptSetting(param: any, oldSettingEntity?: AccessEntity) {
const accessType = param.type;
const accessDefine: AccessDefine = accessRegistry.getDefine(accessType);
if (!accessDefine) {
throw new ValidateException(`授权类型${accessType}不存在`);
}
const setting = param.setting;
if (!setting) {
return;
}
const json = JSON.parse(setting);
let oldSetting = {};
let encryptSetting = {};
const firstEncrypt = !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === '{}';
if (oldSettingEntity) {
oldSetting = JSON.parse(oldSettingEntity.setting || '{}');
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || '{}');
}
for (const key in json) {
//加密
const value = json[key];
const accessInputDefine = accessDefine.input[key];
if (!accessInputDefine) {
throw new ValidateException(`授权类型${accessType}不存在字段${key}`);
}
if (!accessInputDefine.encrypt || !value || typeof value !== 'string') {
//定义无需加密、value为空、不是字符串 这些不需要加密
encryptSetting[key] = {
value: value,
encrypt: false,
};
continue;
}
if (firstEncrypt || oldSetting[key] !== value) {
//星号保护
const length = value.length;
const subIndex = Math.min(2, length);
const starLength = length - subIndex * 2;
const starString = '*'.repeat(starLength);
json[key] = value.substring(0, subIndex) + starString + value.substring(value.length - subIndex);
encryptSetting[key] = {
value: this.encryptService.encrypt(value),
encrypt: true,
};
}
//未改变情况下,不做修改
}
param.encryptSetting = JSON.stringify(encryptSetting);
param.setting = JSON.stringify(json);
}
/**
*
* @param param
*/
async update(param) {
const oldEntity = await this.info(param.id);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
}
this.encryptSetting(param, oldEntity);
return await super.update(param);
}
async getById(id: any): Promise<any> {
const entity = await this.info(id);
if (entity == null) {
throw new Error(`该授权配置不存在,请确认是否已被删除:id=${id}`);
}
// const access = accessRegistry.get(entity.type);
const setting = JSON.parse(entity.setting);
let setting = {};
if (entity.encryptSetting && entity.encryptSetting !== '{}') {
setting = JSON.parse(entity.encryptSetting);
for (const key in setting) {
//解密
const encryptValue = setting[key];
let value = encryptValue.value;
if (encryptValue.encrypt) {
value = this.encryptService.decrypt(value);
}
setting[key] = value;
}
} else if (entity.setting) {
setting = JSON.parse(entity.setting);
}
return {
id: entity.id,
...setting,

View File

@ -1,7 +1,7 @@
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { Provide } from '@midwayjs/core';
import { dnsProviderRegistry } from '@certd/plugin-cert';
@Provide()
@Scope(ScopeEnum.Singleton)
export class DnsProviderService {
getList() {
return dnsProviderRegistry.getDefineList();

View File

@ -0,0 +1,44 @@
import { Init, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import crypto from 'crypto';
import { SysSettingsService } from '../../system/service/sys-settings-service.js';
import { SysPrivateSettings } from '../../system/service/models.js';
/**
*
*/
@Provide()
@Scope(ScopeEnum.Singleton)
export class EncryptService {
secretKey: Buffer;
@Inject()
sysSettingService: SysSettingsService;
@Init()
async init() {
const privateInfo: SysPrivateSettings = await this.sysSettingService.getSetting(SysPrivateSettings);
this.secretKey = Buffer.from(privateInfo.encryptSecret, 'base64');
}
// 加密函数
encrypt(text: string) {
const iv = crypto.randomBytes(16); // 初始化向量
// const secretKey = crypto.randomBytes(32);
// const key = Buffer.from(secretKey);
const cipher = crypto.createCipheriv('aes-256-cbc', this.secretKey, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
// 解密函数
decrypt(encryptedText: string) {
const textParts = encryptedText.split(':');
const iv = Buffer.from(textParts.shift(), 'hex');
const encrypted = Buffer.from(textParts.join(':'), 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(this.secretKey), iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
}

View File

@ -19,6 +19,8 @@ export class SysPrivateSettings extends BaseSettings {
static __title__ = '系统私有设置';
static __access__ = 'private';
static __key__ = 'sys.private';
jwtKey?: string;
encryptSecret?: string;
}
export class SysInstallInfo extends BaseSettings {

View File

@ -21,6 +21,7 @@ export class AliyunAccess {
placeholder: 'accessKeySecret',
},
required: true,
encrypt: true,
})
accessKeySecret = '';
}

View File

@ -20,6 +20,7 @@ export class CloudflareAccess {
},
helper: '前往 https://dash.cloudflare.com/profile/api-tokens 获取API令牌 token权限必须包含[Zone区域-Zone区域-Edit编辑], [Zone区域-DNS-Edit编辑]',
required: true,
encrypt: true,
})
apiToken = '';
}

View File

@ -34,6 +34,7 @@ export class DemoAccess implements IAccess {
},
//是否必填
required: true,
encrypt: true,
})
//属性名称
demoKeySecret = '';

View File

@ -38,6 +38,7 @@ export class SshAccess implements IAccess, ConnectConfig {
name: 'a-input-password',
vModel: 'value',
},
encrypt: true,
helper: '登录密码或密钥必填一项',
})
password!: string;
@ -48,6 +49,7 @@ export class SshAccess implements IAccess, ConnectConfig {
name: 'a-textarea',
vModel: 'value',
},
encrypt: true,
})
privateKey!: string;
@ -58,6 +60,7 @@ export class SshAccess implements IAccess, ConnectConfig {
name: 'a-input-password',
vModel: 'value',
},
encrypt: true,
})
passphrase!: string;

View File

@ -21,6 +21,7 @@ export class HuaweiAccess {
placeholder: 'accessKeySecret',
},
required: true,
encrypt: true,
})
accessKeySecret = '';
}

View File

@ -14,6 +14,7 @@ export class K8sAccess {
placeholder: 'kubeconfig',
},
required: true,
encrypt: true,
})
kubeconfig = '';
}

View File

@ -35,6 +35,7 @@ export class DnspodAccess {
component: {
placeholder: '开放接口token',
},
encrypt: true,
rules: [{ required: true, message: '该项必填' }],
})
token = '';

View File

@ -19,6 +19,7 @@ export class TencentAccess {
component: {
placeholder: 'secretKey',
},
encrypt: true,
rules: [{ required: true, message: '该项必填' }],
})
secretKey = '';