mirror of
https://gitee.com/dromara/mayfly-go
synced 2025-11-03 16:00:25 +08:00
Merge branch 'dev'
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"monaco-themes": "^0.4.4",
|
"monaco-themes": "^0.4.4",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^2.1.3",
|
"pinia": "^2.1.3",
|
||||||
|
"qrcode.vue": "^3.4.0",
|
||||||
"screenfull": "^6.0.2",
|
"screenfull": "^6.0.2",
|
||||||
"sortablejs": "^1.13.0",
|
"sortablejs": "^1.13.0",
|
||||||
"sql-formatter": "^12.1.2",
|
"sql-formatter": "^12.1.2",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Api from './Api'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
login: Api.newPost("/sys/accounts/login"),
|
login: Api.newPost("/sys/accounts/login"),
|
||||||
|
otpVerify: Api.newPost("/sys/accounts/otp-verify"),
|
||||||
changePwd: Api.newPost("/sys/accounts/change-pwd"),
|
changePwd: Api.newPost("/sys/accounts/change-pwd"),
|
||||||
getPublicKey: Api.newGet("/common/public-key"),
|
getPublicKey: Api.newGet("/common/public-key"),
|
||||||
getConfigValue: Api.newGet("/sys/configs/value"),
|
getConfigValue: Api.newGet("/sys/configs/value"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import openApi from './openApi';
|
import openApi from './openApi';
|
||||||
|
|
||||||
// 登录是否使用验证码配置key
|
// 登录是否使用验证码配置key
|
||||||
|
const AccountLoginSecurity = "AccountLoginSecurity"
|
||||||
const UseLoginCaptchaConfigKey = "UseLoginCaptcha"
|
const UseLoginCaptchaConfigKey = "UseLoginCaptcha"
|
||||||
const UseWartermarkConfigKey = "UseWartermark"
|
const UseWartermarkConfigKey = "UseWartermark"
|
||||||
|
|
||||||
@@ -22,11 +23,24 @@ export async function getConfigValue(key: string) : Promise<string> {
|
|||||||
* @returns 是否为ture,1: true;其他: false
|
* @returns 是否为ture,1: true;其他: false
|
||||||
*/
|
*/
|
||||||
export async function getBoolConfigValue(key :string, defaultValue :boolean) : Promise<boolean> {
|
export async function getBoolConfigValue(key :string, defaultValue :boolean) : Promise<boolean> {
|
||||||
const value = await getConfigValue(key)
|
const value = await getConfigValue(key);
|
||||||
if (!value) {
|
return convertBool(value, defaultValue);
|
||||||
return defaultValue;
|
|
||||||
}
|
}
|
||||||
return value == "1";
|
|
||||||
|
/**
|
||||||
|
* 获取账号登录安全配置
|
||||||
|
*
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function getAccountLoginSecurity() : Promise<any> {
|
||||||
|
const value = await getConfigValue(AccountLoginSecurity);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const jsonValue = JSON.parse(value);
|
||||||
|
jsonValue.useCaptcha = convertBool(jsonValue.useCaptcha, true);
|
||||||
|
jsonValue.useOtp = convertBool(jsonValue.useOtp, true);
|
||||||
|
return jsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,3 +60,10 @@ export async function useLoginCaptcha() : Promise<boolean> {
|
|||||||
export async function useWartermark() : Promise<boolean> {
|
export async function useWartermark() : Promise<boolean> {
|
||||||
return await getBoolConfigValue(UseWartermarkConfigKey, true)
|
return await getBoolConfigValue(UseWartermarkConfigKey, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertBool(value: string, defaultValue: boolean) {
|
||||||
|
if (!value) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return value == "1" || value == "true";
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
autocomplete="off" @keyup.enter="login" show-password>
|
autocomplete="off" @keyup.enter="login" show-password>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isUseLoginCaptcha" prop="captcha">
|
<el-form-item v-if="accountLoginSecurity.useCaptcha" prop="captcha">
|
||||||
<el-row :gutter="15">
|
<el-row :gutter="15">
|
||||||
<el-col :span="16">
|
<el-col :span="16">
|
||||||
<el-input type="text" maxlength="6" placeholder="请输入验证码" prefix-icon="position"
|
<el-input type="text" maxlength="6" placeholder="请输入验证码" prefix-icon="position"
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span v-if="showLoginFailTips" style="color: #f56c6c;font-size: 12px;">
|
||||||
|
提示:登录失败超过{{ accountLoginSecurity.loginFailCount }}次后将被限制{{ accountLoginSecurity.loginFailMin }}分钟内不可再次登录
|
||||||
|
</span>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" class="login-content-submit" round @click="login" :loading="loading.signIn">
|
<el-button type="primary" class="login-content-submit" round @click="login" :loading="loading.signIn">
|
||||||
<span>登 录</span>
|
<span>登 录</span>
|
||||||
@@ -34,8 +37,7 @@
|
|||||||
|
|
||||||
<el-dialog title="修改密码" v-model="changePwdDialog.visible" :close-on-click-modal="false" width="450px"
|
<el-dialog title="修改密码" v-model="changePwdDialog.visible" :close-on-click-modal="false" width="450px"
|
||||||
:destroy-on-close="true">
|
:destroy-on-close="true">
|
||||||
<el-form :model="changePwdDialog.form" :rules="changePwdDialog.rules" ref="changePwdFormRef"
|
<el-form :model="changePwdDialog.form" :rules="changePwdDialog.rules" ref="changePwdFormRef" label-width="65px">
|
||||||
label-width="65px">
|
|
||||||
<el-form-item prop="username" label="用户名" required>
|
<el-form-item prop="username" label="用户名" required>
|
||||||
<el-input v-model.trim="changePwdDialog.form.username" disabled></el-input>
|
<el-input v-model.trim="changePwdDialog.form.username" disabled></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -56,6 +58,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog title="OTP校验" v-model="otpDialog.visible" @close="loading.signIn = false" :close-on-click-modal="false"
|
||||||
|
width="450px" :destroy-on-close="true">
|
||||||
|
<el-form ref="otpFormRef" :model="otpDialog.form" :rules="otpDialog.rules" label-width="65px">
|
||||||
|
<el-form-item v-if="otpDialog.otpUrl" label="二维码">
|
||||||
|
<qrcode-vue :value="otpDialog.otpUrl" :size="200" level="H" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="code" label="OTP" required>
|
||||||
|
<el-input ref="otpCodeInputRef" v-model.trim="otpDialog.form.code" clearable @keyup.enter="otpVerify"
|
||||||
|
placeholder="请输入双因素认证APP中显示的授权码"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="otpVerify" type="primary" :loading="loading.otpConfirm">确 定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -68,9 +90,10 @@ import { setSession, setUserInfo2Session, setUseWatermark2Session } from '@/comm
|
|||||||
import { formatAxis } from '@/common/utils/format';
|
import { formatAxis } from '@/common/utils/format';
|
||||||
import openApi from '@/common/openApi';
|
import openApi from '@/common/openApi';
|
||||||
import { RsaEncrypt } from '@/common/rsa';
|
import { RsaEncrypt } from '@/common/rsa';
|
||||||
import { useLoginCaptcha, useWartermark } from '@/common/sysconfig';
|
import { getAccountLoginSecurity, useWartermark } from '@/common/sysconfig';
|
||||||
import { letterAvatar } from '@/common/utils/string';
|
import { letterAvatar } from '@/common/utils/string';
|
||||||
import { useUserInfo } from '@/store/userInfo';
|
import { useUserInfo } from '@/store/userInfo';
|
||||||
|
import QrcodeVue from 'qrcode.vue'
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
@@ -82,9 +105,17 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loginFormRef: any = ref(null);
|
const loginFormRef: any = ref(null);
|
||||||
const changePwdFormRef: any = ref(null);
|
const changePwdFormRef: any = ref(null);
|
||||||
|
const otpFormRef: any = ref(null);
|
||||||
|
const otpCodeInputRef: any = ref(null);
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
isUseLoginCaptcha: false,
|
accountLoginSecurity: {
|
||||||
|
useCaptcha: true,
|
||||||
|
useOtp: false,
|
||||||
|
loginFailCount: 5,
|
||||||
|
loginFailMin: 10,
|
||||||
|
},
|
||||||
|
showLoginFailTips: false,
|
||||||
captchaImage: '',
|
captchaImage: '',
|
||||||
loginForm: {
|
loginForm: {
|
||||||
username: '',
|
username: '',
|
||||||
@@ -110,23 +141,42 @@ const state = reactive({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
otpDialog: {
|
||||||
|
visible: false,
|
||||||
|
otpUrl: "",
|
||||||
|
form: {
|
||||||
|
code: '',
|
||||||
|
otpToken: '',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入OTP授权码', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
loading: {
|
loading: {
|
||||||
signIn: false,
|
signIn: false,
|
||||||
changePwd: false,
|
changePwd: false,
|
||||||
|
otpConfirm: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isUseLoginCaptcha,
|
accountLoginSecurity,
|
||||||
|
showLoginFailTips,
|
||||||
captchaImage,
|
captchaImage,
|
||||||
loginForm,
|
loginForm,
|
||||||
changePwdDialog,
|
changePwdDialog,
|
||||||
|
otpDialog,
|
||||||
loading,
|
loading,
|
||||||
} = toRefs(state)
|
} = toRefs(state)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
nextTick(async () => {
|
nextTick(async () => {
|
||||||
state.isUseLoginCaptcha = await useLoginCaptcha();
|
const res = await getAccountLoginSecurity();
|
||||||
|
if (res) {
|
||||||
|
state.accountLoginSecurity = res;
|
||||||
|
}
|
||||||
getCaptcha();
|
getCaptcha();
|
||||||
});
|
});
|
||||||
// 移除公钥, 方便后续重新获取
|
// 移除公钥, 方便后续重新获取
|
||||||
@@ -134,7 +184,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getCaptcha = async () => {
|
const getCaptcha = async () => {
|
||||||
if (!state.isUseLoginCaptcha) {
|
if (!state.accountLoginSecurity.useCaptcha) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let res: any = await openApi.captcha.request();
|
let res: any = await openApi.captcha.request();
|
||||||
@@ -158,6 +208,22 @@ const login = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const otpVerify = async () => {
|
||||||
|
otpFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.loading.otpConfirm = true;
|
||||||
|
const accessToken = await openApi.otpVerify.request(state.otpDialog.form);
|
||||||
|
await signInSuccess(accessToken);
|
||||||
|
state.otpDialog.visible = false;
|
||||||
|
} finally {
|
||||||
|
state.loading.otpConfirm = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const onSignIn = async () => {
|
const onSignIn = async () => {
|
||||||
state.loading.signIn = true;
|
state.loading.signIn = true;
|
||||||
@@ -167,8 +233,6 @@ const onSignIn = async () => {
|
|||||||
const loginReq = { ...state.loginForm };
|
const loginReq = { ...state.loginForm };
|
||||||
loginReq.password = await RsaEncrypt(originPwd);
|
loginReq.password = await RsaEncrypt(originPwd);
|
||||||
loginRes = await openApi.login.request(loginReq);
|
loginRes = await openApi.login.request(loginReq);
|
||||||
// 存储 token 到浏览器缓存
|
|
||||||
setSession('token', loginRes.token);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
state.loading.signIn = false;
|
state.loading.signIn = false;
|
||||||
state.loginForm.captcha = '';
|
state.loginForm.captcha = '';
|
||||||
@@ -180,9 +244,11 @@ const onSignIn = async () => {
|
|||||||
state.changePwdDialog.visible = true;
|
state.changePwdDialog.visible = true;
|
||||||
} else {
|
} else {
|
||||||
getCaptcha();
|
getCaptcha();
|
||||||
|
state.showLoginFailTips = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
state.showLoginFailTips = false;
|
||||||
// 用户信息
|
// 用户信息
|
||||||
const userInfos = {
|
const userInfos = {
|
||||||
name: loginRes.name,
|
name: loginRes.name,
|
||||||
@@ -190,7 +256,7 @@ const onSignIn = async () => {
|
|||||||
// 头像
|
// 头像
|
||||||
photo: letterAvatar(state.loginForm.username),
|
photo: letterAvatar(state.loginForm.username),
|
||||||
time: new Date().getTime(),
|
time: new Date().getTime(),
|
||||||
permissions: loginRes.permissions,
|
// permissions: loginRes.permissions,
|
||||||
lastLoginTime: loginRes.lastLoginTime,
|
lastLoginTime: loginRes.lastLoginTime,
|
||||||
lastLoginIp: loginRes.lastLoginIp,
|
lastLoginIp: loginRes.lastLoginIp,
|
||||||
};
|
};
|
||||||
@@ -199,12 +265,28 @@ const onSignIn = async () => {
|
|||||||
setUserInfo2Session(userInfos);
|
setUserInfo2Session(userInfos);
|
||||||
// 1、请注意执行顺序(存储用户信息到vuex)
|
// 1、请注意执行顺序(存储用户信息到vuex)
|
||||||
useUserInfo().setUserInfo(userInfos);
|
useUserInfo().setUserInfo(userInfos);
|
||||||
await initRouter();
|
|
||||||
signInSuccess();
|
const token = loginRes.token;
|
||||||
|
// 如果不需要otp校验,则该token即为accessToken,否则为otp校验token
|
||||||
|
if (loginRes.otp == -1) {
|
||||||
|
signInSuccess(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.otpDialog.form.otpToken = token;
|
||||||
|
state.otpDialog.otpUrl = loginRes.otpUrl
|
||||||
|
state.otpDialog.visible = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
otpCodeInputRef.value.focus();
|
||||||
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 登录成功后的跳转
|
// 登录成功后的跳转
|
||||||
const signInSuccess = () => {
|
const signInSuccess = async (accessToken: string = "") => {
|
||||||
|
// 存储 token 到浏览器缓存
|
||||||
|
setSession('token', accessToken);
|
||||||
|
// 初始化路由
|
||||||
|
await initRouter();
|
||||||
// 初始化登录成功时间问候语
|
// 初始化登录成功时间问候语
|
||||||
let currentTimeInfo = currentTime.value;
|
let currentTimeInfo = currentTime.value;
|
||||||
// 登录成功,跳到转首页
|
// 登录成功,跳到转首页
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<el-button v-auth="'account:del'" :disabled="chooseId == null" @click="deleteAccount()" type="danger"
|
<el-button v-auth="'account:del'" :disabled="chooseId == null" @click="deleteAccount()" type="danger"
|
||||||
icon="delete">删除</el-button>
|
icon="delete">删除</el-button>
|
||||||
<div style="float: right">
|
<div style="float: right">
|
||||||
<el-input class="mr2" placeholder="请输入账号名" style="width: 200px" v-model="query.username"
|
<el-input class="mr2" placeholder="请输入账号名" style="width: 200px" v-model="query.username" @clear="search()"
|
||||||
@clear="search()" clearable></el-input>
|
clearable></el-input>
|
||||||
<el-button @click="search()" type="success" icon="search"></el-button>
|
<el-button @click="search()" type="success" icon="search"></el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="datas" ref="table" @current-change="choose" show-overflow-tooltip>
|
<el-table :data="datas" ref="table" @current-change="choose" show-overflow-tooltip>
|
||||||
@@ -42,12 +42,6 @@
|
|||||||
{{ dateFormat(scope.row.createTime) }}
|
{{ dateFormat(scope.row.createTime) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- <el-table-column min-width="115" prop="modifier" label="更新账号"></el-table-column>
|
|
||||||
<el-table-column min-width="160" prop="updateTime" label="修改时间">
|
|
||||||
<template #default="scope">
|
|
||||||
{{ dateFormat(scope.row.updateTime) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column> -->
|
|
||||||
|
|
||||||
<!-- <el-table-column min-width="120" prop="remark" label="备注" show-overflow-tooltip></el-table-column> -->
|
<!-- <el-table-column min-width="120" prop="remark" label="备注" show-overflow-tooltip></el-table-column> -->
|
||||||
<el-table-column label="查看更多" min-width="150">
|
<el-table-column label="查看更多" min-width="150">
|
||||||
@@ -61,9 +55,13 @@
|
|||||||
<el-table-column label="操作" min-width="200px">
|
<el-table-column label="操作" min-width="200px">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button v-auth="'account:changeStatus'" @click="changeStatus(scope.row)"
|
<el-button v-auth="'account:changeStatus'" @click="changeStatus(scope.row)"
|
||||||
v-if="scope.row.status == 1" type="danger" icom="tickets" size="small" plain>禁用</el-button>
|
v-if="scope.row.status == 1" type="danger" size="small" plain>禁用</el-button>
|
||||||
|
|
||||||
<el-button v-auth="'account:changeStatus'" v-if="scope.row.status == -1" type="success"
|
<el-button v-auth="'account:changeStatus'" v-if="scope.row.status == -1" type="success"
|
||||||
@click="changeStatus(scope.row)" size="small" plain>启用</el-button>
|
@click="changeStatus(scope.row)" size="small" plain>启用</el-button>
|
||||||
|
|
||||||
|
<el-button v-auth="'account:add'" @click="resetOtpSecret(scope.row)" type="warning" size="small"
|
||||||
|
plain>重置OTP</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -215,6 +213,14 @@ const changeStatus = async (row: any) => {
|
|||||||
search();
|
search();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetOtpSecret = async (row: any) => {
|
||||||
|
let id = row.id;
|
||||||
|
await accountApi.resetOtpSecret.request({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
ElMessage.success('操作成功');
|
||||||
|
};
|
||||||
|
|
||||||
const handlePageChange = (curPage: number) => {
|
const handlePageChange = (curPage: number) => {
|
||||||
state.query.pageNum = curPage;
|
state.query.pageNum = curPage;
|
||||||
search();
|
search();
|
||||||
@@ -263,6 +269,4 @@ const deleteAccount = async () => {
|
|||||||
} catch (err) { }
|
} catch (err) { }
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss"></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const accountApi = {
|
|||||||
update: Api.newPut("/sys/accounts/{id}"),
|
update: Api.newPut("/sys/accounts/{id}"),
|
||||||
del: Api.newDelete("/sys/accounts/{id}"),
|
del: Api.newDelete("/sys/accounts/{id}"),
|
||||||
changeStatus: Api.newPut("/sys/accounts/change-status/{id}/{status}"),
|
changeStatus: Api.newPut("/sys/accounts/change-status/{id}/{status}"),
|
||||||
|
resetOtpSecret: Api.newPut("/sys/accounts/{id}/reset-otp"),
|
||||||
roleIds: Api.newGet("/sys/accounts/{id}/roleIds"),
|
roleIds: Api.newGet("/sys/accounts/{id}/roleIds"),
|
||||||
roles: Api.newGet("/sys/accounts/{id}/roles"),
|
roles: Api.newGet("/sys/accounts/{id}/roles"),
|
||||||
resources: Api.newGet("/sys/accounts/{id}/resources"),
|
resources: Api.newGet("/sys/accounts/{id}/resources"),
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
<el-table-column prop="modifier" label="修改者" min-width="60px" show-overflow-tooltip></el-table-column>
|
<el-table-column prop="modifier" label="修改者" min-width="60px" show-overflow-tooltip></el-table-column>
|
||||||
<el-table-column label="操作" min-width="50" fixed="right">
|
<el-table-column label="操作" min-width="50" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link :disabled="scope.row.status == -1" type="warning"
|
<el-link :disabled="scope.row.status == -1" type="warning" @click="showSetConfigDialog(scope.row)"
|
||||||
@click="showSetConfigDialog(scope.row)" plain size="small" :underline="false">配置</el-link>
|
plain size="small" :underline="false">配置</el-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -38,20 +38,20 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog :before-close="closeSetConfigDialog" title="配置项设置" v-model="paramsDialog.visible" width="500px">
|
<el-dialog :before-close="closeSetConfigDialog" title="配置项设置" v-model="paramsDialog.visible" width="500px">
|
||||||
<el-form v-if="paramsDialog.paramsFormItem.length > 0" ref="paramsForm" :model="paramsDialog.params"
|
<el-form v-if="paramsDialog.paramsFormItem.length > 0" ref="paramsFormRef" :model="paramsDialog.params"
|
||||||
label-width="90px">
|
label-width="130px">
|
||||||
<el-form-item v-for="item in paramsDialog.paramsFormItem" :key="item.name" :prop="item.model"
|
<el-form-item v-for="item in paramsDialog.paramsFormItem" :key="item.name" :prop="item.model"
|
||||||
:label="item.name" required>
|
:label="item.name" required>
|
||||||
<el-input v-if="!item.options" v-model="paramsDialog.params[item.model]"
|
<el-input v-if="!item.options" v-model="paramsDialog.params[item.model]" :placeholder="item.placeholder"
|
||||||
:placeholder="item.placeholder" autocomplete="off" clearable></el-input>
|
autocomplete="off" clearable></el-input>
|
||||||
<el-select v-else v-model="paramsDialog.params[item.model]" :placeholder="item.placeholder"
|
<el-select v-else v-model="paramsDialog.params[item.model]" :placeholder="item.placeholder" filterable
|
||||||
filterable autocomplete="off" clearable style="width: 100%">
|
autocomplete="off" clearable style="width: 100%">
|
||||||
<el-option v-for="option in item.options.split(',')" :key="option" :label="option"
|
<el-option v-for="option in item.options.split(',')" :key="option" :label="option"
|
||||||
:value="option" />
|
:value="option" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-form v-else ref="paramsForm" label-width="90px">
|
<el-form v-else ref="paramsFormRef" label-width="90px">
|
||||||
<el-form-item label="配置值" required>
|
<el-form-item label="配置值" required>
|
||||||
<el-input v-model="paramsDialog.params" :placeholder="paramsDialog.config.remark" autocomplete="off"
|
<el-input v-model="paramsDialog.params" :placeholder="paramsDialog.config.remark" autocomplete="off"
|
||||||
clearable></el-input>
|
clearable></el-input>
|
||||||
@@ -71,12 +71,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs, reactive, onMounted } from 'vue';
|
import { ref, toRefs, reactive, onMounted } from 'vue';
|
||||||
import ConfigEdit from './ConfigEdit.vue';
|
import ConfigEdit from './ConfigEdit.vue';
|
||||||
import { configApi } from '../api';
|
import { configApi } from '../api';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { dateFormat } from '@/common/utils/date';
|
import { dateFormat } from '@/common/utils/date';
|
||||||
|
|
||||||
|
const paramsFormRef: any = ref(null)
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
query: {
|
query: {
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
@@ -153,6 +154,10 @@ const closeSetConfigDialog = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setConfig = async () => {
|
const setConfig = async () => {
|
||||||
|
paramsFormRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let paramsValue = state.paramsDialog.params;
|
let paramsValue = state.paramsDialog.params;
|
||||||
if (state.paramsDialog.paramsFormItem.length > 0) {
|
if (state.paramsDialog.paramsFormItem.length > 0) {
|
||||||
// 如果配置项删除,则需要将value中对应的字段移除
|
// 如果配置项删除,则需要将value中对应的字段移除
|
||||||
@@ -172,6 +177,8 @@ const setConfig = async () => {
|
|||||||
ElMessage.success('保存成功');
|
ElMessage.success('保存成功');
|
||||||
closeSetConfigDialog();
|
closeSetConfigDialog();
|
||||||
search();
|
search();
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasParam = (paramKey: string, paramItems: any) => {
|
const hasParam = (paramKey: string, paramItems: any) => {
|
||||||
@@ -208,6 +215,4 @@ const editConfig = (data: any) => {
|
|||||||
state.configEdit.visible = true;
|
state.configEdit.visible = true;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss"></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1604,6 +1604,11 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.nlark.com/punycode/download/punycode-2.1.1.tgz"
|
resolved "https://registry.nlark.com/punycode/download/punycode-2.1.1.tgz"
|
||||||
integrity sha1-tYsBCsQMIsVldhbI0sLALHv0eew=
|
integrity sha1-tYsBCsQMIsVldhbI0sLALHv0eew=
|
||||||
|
|
||||||
|
qrcode.vue@^3.4.0:
|
||||||
|
version "3.4.0"
|
||||||
|
resolved "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.4.0.tgz#4513ff1a4734cb7184086c2fd439f0d462c6d281"
|
||||||
|
integrity sha512-4XeImbv10Fin16Fl2DArCMhGyAdvIg2jb7vDT+hZiIAMg/6H6mz9nUZr/dR8jBcun5VzNzkiwKhiqOGbloinwA==
|
||||||
|
|
||||||
queue-microtask@^1.2.2:
|
queue-microtask@^1.2.2:
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.nlark.com/queue-microtask/download/queue-microtask-1.2.3.tgz"
|
resolved "https://registry.nlark.com/queue-microtask/download/queue-microtask-1.2.3.tgz"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ mysql:
|
|||||||
# redis:
|
# redis:
|
||||||
# host: localhost
|
# host: localhost
|
||||||
# port: 6379
|
# port: 6379
|
||||||
# passsord:
|
# password: 111049
|
||||||
# db: 0
|
# db: 0
|
||||||
log:
|
log:
|
||||||
# 日志等级, trace, debug, info, warn, error, fatal
|
# 日志等级, trace, debug, info, warn, error, fatal
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/lib/pq v1.10.7
|
github.com/lib/pq v1.10.7
|
||||||
github.com/mojocn/base64Captcha v1.3.5 // 验证码
|
github.com/mojocn/base64Captcha v1.3.5 // 验证码
|
||||||
github.com/pkg/sftp v1.13.5
|
github.com/pkg/sftp v1.13.5
|
||||||
|
github.com/pquerna/otp v1.4.0
|
||||||
github.com/redis/go-redis/v9 v9.0.5
|
github.com/redis/go-redis/v9 v9.0.5
|
||||||
github.com/robfig/cron/v3 v3.0.1 // 定时任务
|
github.com/robfig/cron/v3 v3.0.1 // 定时任务
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
@@ -23,6 +24,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mayfly-go/internal/sys/api/form"
|
"mayfly-go/internal/sys/api/form"
|
||||||
"mayfly-go/internal/sys/api/vo"
|
"mayfly-go/internal/sys/api/vo"
|
||||||
"mayfly-go/internal/sys/application"
|
"mayfly-go/internal/sys/application"
|
||||||
"mayfly-go/internal/sys/domain/entity"
|
"mayfly-go/internal/sys/domain/entity"
|
||||||
"mayfly-go/pkg/biz"
|
"mayfly-go/pkg/biz"
|
||||||
|
"mayfly-go/pkg/cache"
|
||||||
"mayfly-go/pkg/captcha"
|
"mayfly-go/pkg/captcha"
|
||||||
"mayfly-go/pkg/ginx"
|
"mayfly-go/pkg/ginx"
|
||||||
"mayfly-go/pkg/model"
|
"mayfly-go/pkg/model"
|
||||||
|
"mayfly-go/pkg/otp"
|
||||||
"mayfly-go/pkg/req"
|
"mayfly-go/pkg/req"
|
||||||
"mayfly-go/pkg/utils"
|
"mayfly-go/pkg/utils"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -18,6 +21,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OtpStatusNone = -1 // 未启用otp校验
|
||||||
|
OtpStatusReg = 1 // 用户otp secret已注册
|
||||||
|
OtpStatusNoReg = 2 // 用户otp secret未注册
|
||||||
|
)
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
AccountApp application.Account
|
AccountApp application.Account
|
||||||
ResourceApp application.Resource
|
ResourceApp application.Resource
|
||||||
@@ -33,39 +42,141 @@ func (a *Account) Login(rc *req.Ctx) {
|
|||||||
loginForm := &form.LoginForm{}
|
loginForm := &form.LoginForm{}
|
||||||
ginx.BindJsonAndValid(rc.GinCtx, loginForm)
|
ginx.BindJsonAndValid(rc.GinCtx, loginForm)
|
||||||
|
|
||||||
|
accountLoginSecurity := a.ConfigApp.GetConfig(entity.ConfigKeyAccountLoginSecurity).ToAccountLoginSecurity()
|
||||||
// 判断是否有开启登录验证码校验
|
// 判断是否有开启登录验证码校验
|
||||||
if a.ConfigApp.GetConfig(entity.ConfigKeyUseLoginCaptcha).BoolValue(true) {
|
if accountLoginSecurity.UseCaptcha {
|
||||||
// 校验验证码
|
// 校验验证码
|
||||||
biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")
|
biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username := loginForm.Username
|
||||||
clientIp := rc.GinCtx.ClientIP()
|
clientIp := rc.GinCtx.ClientIP()
|
||||||
rc.ReqParam = loginForm.Username
|
rc.ReqParam = fmt.Sprintf("username: %s | ip: %s", username, clientIp)
|
||||||
rc.ReqParam = fmt.Sprintf("username: %s | ip: %s", loginForm.Username, clientIp)
|
|
||||||
|
|
||||||
originPwd, err := utils.DefaultRsaDecrypt(loginForm.Password, true)
|
originPwd, err := utils.DefaultRsaDecrypt(loginForm.Password, true)
|
||||||
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
|
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
|
||||||
|
|
||||||
account := &entity.Account{Username: loginForm.Username}
|
account := &entity.Account{Username: username}
|
||||||
err = a.AccountApp.GetAccount(account, "Id", "Name", "Username", "Password", "Status", "LastLoginTime", "LastLoginIp")
|
err = a.AccountApp.GetAccount(account, "Id", "Name", "Username", "Password", "Status", "LastLoginTime", "LastLoginIp", "OtpSecret")
|
||||||
biz.ErrIsNil(err, "用户名或密码错误")
|
|
||||||
biz.IsTrue(utils.CheckPwdHash(originPwd, account.Password), "用户名或密码错误")
|
failCountKey := fmt.Sprintf("account:login:failcount:%s", username)
|
||||||
|
nowFailCount := cache.GetInt(failCountKey)
|
||||||
|
loginFailCount := accountLoginSecurity.LoginFailCount
|
||||||
|
loginFailMin := accountLoginSecurity.LoginFailMin
|
||||||
|
biz.IsTrue(nowFailCount < loginFailCount, fmt.Sprintf("登录失败超过%d次, 请%d分钟后再试", loginFailCount, loginFailMin))
|
||||||
|
|
||||||
|
if err != nil || !utils.CheckPwdHash(originPwd, account.Password) {
|
||||||
|
nowFailCount++
|
||||||
|
cache.SetStr(failCountKey, strconv.Itoa(nowFailCount), time.Minute*time.Duration(loginFailMin))
|
||||||
|
panic(biz.NewBizErr(fmt.Sprintf("用户名或密码错误【当前登录失败%d次】", nowFailCount)))
|
||||||
|
}
|
||||||
biz.IsTrue(account.IsEnable(), "该账号不可用")
|
biz.IsTrue(account.IsEnable(), "该账号不可用")
|
||||||
|
|
||||||
// 校验密码强度是否符合
|
// 校验密码强度(新用户第一次登录密码与账号名一致)
|
||||||
biz.IsTrueBy(CheckPasswordLever(originPwd), biz.NewBizErrCode(401, "您的密码安全等级较低,请修改后重新登录"))
|
biz.IsTrueBy(CheckPasswordLever(originPwd), biz.NewBizErrCode(401, "您的密码安全等级较低,请修改后重新登录"))
|
||||||
// 保存登录消息
|
|
||||||
go a.saveLogin(account, clientIp)
|
|
||||||
|
|
||||||
rc.ResData = map[string]any{
|
res := map[string]any{
|
||||||
"token": req.CreateToken(account.Id, account.Username),
|
|
||||||
"name": account.Name,
|
"name": account.Name,
|
||||||
"username": account.Username,
|
"username": username,
|
||||||
"lastLoginTime": account.LastLoginTime,
|
"lastLoginTime": account.LastLoginTime,
|
||||||
"lastLoginIp": account.LastLoginIp,
|
"lastLoginIp": account.LastLoginIp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认为不校验otp
|
||||||
|
otpStatus := OtpStatusNone
|
||||||
|
var token string
|
||||||
|
// 访问系统使用的token
|
||||||
|
accessToken := req.CreateToken(account.Id, username)
|
||||||
|
// 若系统配置中设置开启otp双因素校验,则进行otp校验
|
||||||
|
if accountLoginSecurity.UseOtp {
|
||||||
|
otpSecret := account.OtpSecret
|
||||||
|
// 修改状态为已注册
|
||||||
|
otpStatus = OtpStatusReg
|
||||||
|
// 该token用于opt双因素校验
|
||||||
|
token = utils.RandString(32)
|
||||||
|
// 未注册otp secret或重置了秘钥
|
||||||
|
if otpSecret == "" || otpSecret == "-" {
|
||||||
|
otpStatus = OtpStatusNoReg
|
||||||
|
key, err := otp.NewTOTP(otp.GenerateOpts{
|
||||||
|
AccountName: username,
|
||||||
|
Issuer: accountLoginSecurity.OtpIssuer,
|
||||||
|
})
|
||||||
|
biz.ErrIsNilAppendErr(err, "otp生成失败: %s")
|
||||||
|
res["otpUrl"] = key.URL()
|
||||||
|
// 使用otpSecret充当token进行二次校验
|
||||||
|
otpSecret = key.Secret()
|
||||||
|
}
|
||||||
|
// 缓存otpInfo, 只有双因素校验通过才可返回真正的accessToken
|
||||||
|
otpInfo := &OtpVerifyInfo{
|
||||||
|
AccountId: account.Id,
|
||||||
|
Username: username,
|
||||||
|
OptStatus: otpStatus,
|
||||||
|
OtpSecret: otpSecret,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
}
|
||||||
|
cache.SetStr(fmt.Sprintf("otp:token:%s", token), utils.ToJsonStr(otpInfo), time.Minute*time.Duration(3))
|
||||||
|
} else {
|
||||||
|
// 不进行otp二次校验则直接返回accessToken
|
||||||
|
token = accessToken
|
||||||
|
// 保存登录消息
|
||||||
|
go a.saveLogin(account, clientIp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 赋值otp状态
|
||||||
|
res["otp"] = otpStatus
|
||||||
|
res["token"] = token
|
||||||
|
rc.ResData = res
|
||||||
|
}
|
||||||
|
|
||||||
|
type OtpVerifyInfo struct {
|
||||||
|
AccountId uint64
|
||||||
|
Username string
|
||||||
|
OptStatus int
|
||||||
|
AccessToken string
|
||||||
|
OtpSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP双因素校验
|
||||||
|
func (a *Account) OtpVerify(rc *req.Ctx) {
|
||||||
|
otpVerify := new(form.OtpVerfiy)
|
||||||
|
ginx.BindJsonAndValid(rc.GinCtx, otpVerify)
|
||||||
|
|
||||||
|
tokenKey := fmt.Sprintf("otp:token:%s", otpVerify.OtpToken)
|
||||||
|
otpInfoJson := cache.GetStr(tokenKey)
|
||||||
|
biz.NotEmpty(otpInfoJson, "otpToken错误或失效, 请重新登陆获取")
|
||||||
|
otpInfo := new(OtpVerifyInfo)
|
||||||
|
json.Unmarshal([]byte(otpInfoJson), otpInfo)
|
||||||
|
|
||||||
|
failCountKey := fmt.Sprintf("account:otp:failcount:%d", otpInfo.AccountId)
|
||||||
|
failCount := cache.GetInt(failCountKey)
|
||||||
|
biz.IsTrue(failCount < 5, "双因素校验失败超过5次, 请10分钟后再试")
|
||||||
|
|
||||||
|
otpStatus := otpInfo.OptStatus
|
||||||
|
accessToken := otpInfo.AccessToken
|
||||||
|
accountId := otpInfo.AccountId
|
||||||
|
otpSecret := otpInfo.OtpSecret
|
||||||
|
|
||||||
|
if !otp.Validate(otpVerify.Code, otpSecret) {
|
||||||
|
cache.SetStr(failCountKey, strconv.Itoa(failCount+1), time.Minute*time.Duration(10))
|
||||||
|
panic(biz.NewBizErr("双因素认证授权码不正确"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是未注册状态,则更新account表的otpSecret信息
|
||||||
|
if otpStatus == OtpStatusNoReg {
|
||||||
|
update := &entity.Account{OtpSecret: otpSecret}
|
||||||
|
update.Id = accountId
|
||||||
|
a.AccountApp.Update(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
la := &entity.Account{Username: otpInfo.Username}
|
||||||
|
la.Id = accountId
|
||||||
|
go a.saveLogin(la, rc.GinCtx.ClientIP())
|
||||||
|
|
||||||
|
cache.Del(tokenKey)
|
||||||
|
rc.ResData = accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前登录用户的菜单与权限码
|
||||||
func (a *Account) GetPermissions(rc *req.Ctx) {
|
func (a *Account) GetPermissions(rc *req.Ctx) {
|
||||||
account := rc.LoginAccount
|
account := rc.LoginAccount
|
||||||
|
|
||||||
@@ -296,3 +407,12 @@ func (a *Account) SaveRoles(rc *req.Ctx) {
|
|||||||
a.RoleApp.DeleteAccountRole(aid, v.(uint64))
|
a.RoleApp.DeleteAccountRole(aid, v.(uint64))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置otp秘钥
|
||||||
|
func (a *Account) ResetOtpSecret(rc *req.Ctx) {
|
||||||
|
account := &entity.Account{OtpSecret: "-"}
|
||||||
|
accountId := uint64(ginx.PathParamInt(rc.GinCtx, "id"))
|
||||||
|
account.Id = accountId
|
||||||
|
rc.ReqParam = fmt.Sprintf("accountId = %d", accountId)
|
||||||
|
a.AccountApp.Update(account)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ type LoginForm struct {
|
|||||||
Captcha string `json:"captcha"`
|
Captcha string `json:"captcha"`
|
||||||
Cid string `json:"cid"`
|
Cid string `json:"cid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OtpVerfiy struct {
|
||||||
|
OtpToken string `json:"otpToken" binding:"required"`
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (a *configAppImpl) GetConfig(key string) *entity.Config {
|
|||||||
if err := a.configRepo.GetConfig(config, "Id", "Key", "Value"); err != nil {
|
if err := a.configRepo.GetConfig(config, "Id", "Key", "Value"); err != nil {
|
||||||
global.Log.Warnf("不存在key = [%s] 的系统配置", key)
|
global.Log.Warnf("不存在key = [%s] 的系统配置", key)
|
||||||
} else {
|
} else {
|
||||||
cache.SetStr(SysConfigKeyPrefix+key, utils.ToJsonStr(config))
|
cache.SetStr(SysConfigKeyPrefix+key, utils.ToJsonStr(config), -1)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Account struct {
|
|||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
LastLoginTime *time.Time `json:"lastLoginTime"`
|
LastLoginTime *time.Time `json:"lastLoginTime"`
|
||||||
LastLoginIp string `json:"lastLoginIp"`
|
LastLoginIp string `json:"lastLoginIp"`
|
||||||
|
OtpSecret string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) TableName() string {
|
func (a *Account) TableName() string {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
ConfigKeyAccountLoginSecurity string = "AccountLoginSecurity" // 账号登录安全配置
|
||||||
ConfigKeyUseLoginCaptcha string = "UseLoginCaptcha" // 是否使用登录验证码
|
ConfigKeyUseLoginCaptcha string = "UseLoginCaptcha" // 是否使用登录验证码
|
||||||
|
ConfigKeyUseLoginOtp string = "UseLoginOtp" // 是否开启otp双因素校验
|
||||||
ConfigKeyDbQueryMaxCount string = "DbQueryMaxCount" // 数据库查询的最大数量
|
ConfigKeyDbQueryMaxCount string = "DbQueryMaxCount" // 数据库查询的最大数量
|
||||||
ConfigKeyDbSaveQuerySQL string = "DbSaveQuerySQL" // 数据库是否记录查询相关sql
|
ConfigKeyDbSaveQuerySQL string = "DbSaveQuerySQL" // 数据库是否记录查询相关sql
|
||||||
)
|
)
|
||||||
@@ -26,13 +28,12 @@ func (a *Config) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 若配置信息不存在, 则返回传递的默认值.
|
// 若配置信息不存在, 则返回传递的默认值.
|
||||||
// 否则只有value == "1"为true,其他为false
|
|
||||||
func (c *Config) BoolValue(defaultValue bool) bool {
|
func (c *Config) BoolValue(defaultValue bool) bool {
|
||||||
// 如果值不存在,则返回默认值
|
// 如果值不存在,则返回默认值
|
||||||
if c.Id == 0 {
|
if c.Id == 0 {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
return c.Value == "1"
|
return convertBool(c.Value, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 值返回json map
|
// 值返回json map
|
||||||
@@ -51,7 +52,47 @@ func (c *Config) IntValue(defaultValue int) int {
|
|||||||
if c.Id == 0 {
|
if c.Id == 0 {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
if intV, err := strconv.Atoi(c.Value); err != nil {
|
return convertInt(c.Value, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountLoginSecurity struct {
|
||||||
|
UseCaptcha bool // 是否使用登录验证码
|
||||||
|
UseOtp bool // 是否双因素校验
|
||||||
|
OtpIssuer string // otp发行人
|
||||||
|
LoginFailCount int // 允许失败次数
|
||||||
|
LoginFailMin int // 登录失败指定次数后禁止的分钟数
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为AccountLoginSecurity结构体
|
||||||
|
func (c *Config) ToAccountLoginSecurity() *AccountLoginSecurity {
|
||||||
|
jm := c.GetJsonMap()
|
||||||
|
als := new(AccountLoginSecurity)
|
||||||
|
als.UseCaptcha = convertBool(jm["useCaptcha"], true)
|
||||||
|
als.UseOtp = convertBool(jm["useOtp"], false)
|
||||||
|
als.LoginFailCount = convertInt(jm["loginFailCount"], 5)
|
||||||
|
als.LoginFailMin = convertInt(jm["loginFailMin"], 10)
|
||||||
|
otpIssuer := jm["otpIssuer"]
|
||||||
|
if otpIssuer == "" {
|
||||||
|
otpIssuer = "mayfly-go"
|
||||||
|
}
|
||||||
|
als.OtpIssuer = otpIssuer
|
||||||
|
return als
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换配置中的值为bool类型(默认"1"或"true"为true,其他为false)
|
||||||
|
func convertBool(value string, defaultValue bool) bool {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value == "1" || value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换配置值中的值为int
|
||||||
|
func convertInt(value string, defaultValue int) int {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
if intV, err := strconv.Atoi(value); err != nil {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
} else {
|
} else {
|
||||||
return intV
|
return intV
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ func InitAccountRouter(router *gin.RouterGroup) {
|
|||||||
Handle(a.Login)
|
Handle(a.Login)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
account.POST("otp-verify", func(g *gin.Context) {
|
||||||
|
req.NewCtxWithGin(g).
|
||||||
|
DontNeedToken().
|
||||||
|
Handle(a.OtpVerify)
|
||||||
|
})
|
||||||
|
|
||||||
// 获取个人账号的权限资源信息
|
// 获取个人账号的权限资源信息
|
||||||
account.GET("/permissions", func(c *gin.Context) {
|
account.GET("/permissions", func(c *gin.Context) {
|
||||||
req.NewCtxWithGin(c).Handle(a.GetPermissions)
|
req.NewCtxWithGin(c).Handle(a.GetPermissions)
|
||||||
@@ -77,6 +83,14 @@ func InitAccountRouter(router *gin.RouterGroup) {
|
|||||||
Handle(a.ChangeStatus)
|
Handle(a.ChangeStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
resetOtpSecret := req.NewLogInfo("重置OTP密钥").WithSave(true)
|
||||||
|
account.PUT(":id/reset-otp", func(c *gin.Context) {
|
||||||
|
req.NewCtxWithGin(c).
|
||||||
|
WithRequiredPermission(addAccountPermission).
|
||||||
|
WithLog(resetOtpSecret).
|
||||||
|
Handle(a.ResetOtpSecret)
|
||||||
|
})
|
||||||
|
|
||||||
delAccount := req.NewLogInfo("删除账号").WithSave(true)
|
delAccount := req.NewLogInfo("删除账号").WithSave(true)
|
||||||
delAccountPermission := req.NewPermission("account:del")
|
delAccountPermission := req.NewPermission("account:del")
|
||||||
account.DELETE(":id", func(c *gin.Context) {
|
account.DELETE(":id", func(c *gin.Context) {
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ func InitSysConfigRouter(router *gin.RouterGroup) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
saveConfig := req.NewLogInfo("保存系统配置信息").WithSave(true)
|
saveConfig := req.NewLogInfo("保存系统配置信息").WithSave(true)
|
||||||
|
saveConfigP := req.NewPermission("config:base")
|
||||||
db.POST("", func(c *gin.Context) {
|
db.POST("", func(c *gin.Context) {
|
||||||
req.NewCtxWithGin(c).
|
req.NewCtxWithGin(c).
|
||||||
WithLog(saveConfig).
|
WithLog(saveConfig).
|
||||||
|
WithRequiredPermission(saveConfigP).
|
||||||
Handle(r.SaveConfig)
|
Handle(r.SaveConfig)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,10 +350,10 @@ CREATE TABLE `t_sys_config` (
|
|||||||
-- Records of t_sys_config
|
-- Records of t_sys_config
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
BEGIN;
|
BEGIN;
|
||||||
INSERT INTO `t_sys_config` VALUES (1, '是否启用登录验证码', 'UseLoginCaptcha', NULL, '1', '1: 启用、0: 不启用', '2022-08-25 22:27:17', 1, 'admin', '2022-08-26 10:26:56', 1, 'admin');
|
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier) VALUES(5, '账号登录安全设置', 'AccountLoginSecurity', '[{"name":"登录验证码","model":"useCaptcha","placeholder":"是否启用登录验证码","options":"true,false"},{"name":"双因素校验","model":"useOtp","placeholder":"是否启用双因素(OTP)校验","options":"true,false"},{"name":"otp签发人","model":"otpIssuer","placeholder":"otp签发人"},{"name":"允许失败次数","model":"loginFailCount","placeholder":"登录失败n次后禁止登录"},{"name":"禁止登录时间","model":"loginFailMin","placeholder":"登录失败指定次数后禁止m分钟内再次登录"}]', '{"useCaptcha":"true","useOtp":"false","loginFailCount":"5","loginFailMin":"10","otpIssuer":"mayfly-go"}', '系统账号登录相关安全设置', '2023-06-17 11:02:11', 1, 'admin', '2023-06-17 14:18:07', 1, 'admin');
|
||||||
INSERT INTO `t_sys_config` VALUES (2, '是否启用水印', 'UseWartermark', NULL, '1', '1: 启用、0: 不启用', '2022-08-25 23:36:35', 1, 'admin', '2022-08-26 10:02:52', 1, 'admin');
|
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier)VALUES ('是否启用水印', 'UseWartermark', NULL, '1', '1: 启用、0: 不启用', '2022-08-25 23:36:35', 1, 'admin', '2022-08-26 10:02:52', 1, 'admin');
|
||||||
INSERT INTO `t_sys_config` VALUES (3, '数据库查询最大结果集', 'DbQueryMaxCount', '[]', '200', '允许sql查询的最大结果集数。注: 0=不限制', '2023-02-11 14:29:03', 1, 'admin', '2023-02-11 14:40:56', 1, 'admin');
|
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier)VALUES ('数据库查询最大结果集', 'DbQueryMaxCount', '[]', '200', '允许sql查询的最大结果集数。注: 0=不限制', '2023-02-11 14:29:03', 1, 'admin', '2023-02-11 14:40:56', 1, 'admin');
|
||||||
INSERT INTO `t_sys_config` VALUES (4, '数据库是否记录查询SQL', 'DbSaveQuerySQL', '[]', '0', '1: 记录、0:不记录', '2023-02-11 16:07:14', 1, 'admin', '2023-02-11 16:44:17', 1, 'admin');
|
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier)VALUES ('数据库是否记录查询SQL', 'DbSaveQuerySQL', '[]', '0', '1: 记录、0:不记录', '2023-02-11 16:07:14', 1, 'admin', '2023-02-11 16:44:17', 1, 'admin');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|||||||
61
server/pkg/cache/str_cache.go
vendored
61
server/pkg/cache/str_cache.go
vendored
@@ -1,15 +1,25 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import "mayfly-go/pkg/rediscli"
|
import (
|
||||||
|
"mayfly-go/pkg/global"
|
||||||
|
"mayfly-go/pkg/rediscli"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
var strCache map[string]string
|
var tm *TimedCache
|
||||||
|
|
||||||
// 如果系统有设置redis信息,则从redis获取,否则本机内存获取
|
// 如果系统有设置redis信息,则从redis获取,否则本机内存获取
|
||||||
func GetStr(key string) string {
|
func GetStr(key string) string {
|
||||||
if rediscli.GetCli() == nil {
|
if !useRedisCache() {
|
||||||
checkStrCache()
|
checkCache()
|
||||||
return strCache[key]
|
val, _ := tm.Get(key)
|
||||||
|
if val == nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
return val.(string)
|
||||||
|
}
|
||||||
|
|
||||||
res, err := rediscli.Get(key)
|
res, err := rediscli.Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -17,28 +27,45 @@ func GetStr(key string) string {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果系统有设置redis信息,则使用redis存,否则存于本机内存
|
func GetInt(key string) int {
|
||||||
func SetStr(key, value string) {
|
val := GetStr(key)
|
||||||
if rediscli.GetCli() == nil {
|
if val == "" {
|
||||||
checkStrCache()
|
return 0
|
||||||
strCache[key] = value
|
}
|
||||||
|
if intV, err := strconv.Atoi(val); err != nil {
|
||||||
|
global.Log.Error("获取缓存中的int值转换失败", err)
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
return intV
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果系统有设置redis信息,则使用redis存,否则存于本机内存。duration == -1则为永久缓存
|
||||||
|
func SetStr(key, value string, duration time.Duration) {
|
||||||
|
if !useRedisCache() {
|
||||||
|
checkCache()
|
||||||
|
tm.Add(key, value, duration)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rediscli.Set(key, value, 0)
|
rediscli.Set(key, value, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除指定key
|
// 删除指定key
|
||||||
func Del(key string) {
|
func Del(key string) {
|
||||||
if rediscli.GetCli() == nil {
|
if !useRedisCache() {
|
||||||
checkStrCache()
|
checkCache()
|
||||||
delete(strCache, key)
|
tm.Delete(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rediscli.Del(key)
|
rediscli.Del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkStrCache() {
|
func checkCache() {
|
||||||
if strCache == nil {
|
if tm == nil {
|
||||||
strCache = make(map[string]string)
|
tm = NewTimedCache(time.Minute*time.Duration(5), 30*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func useRedisCache() bool {
|
||||||
|
return rediscli.GetCli() != nil
|
||||||
|
}
|
||||||
|
|||||||
8
server/pkg/cache/timed_cache.go
vendored
8
server/pkg/cache/timed_cache.go
vendored
@@ -63,10 +63,10 @@ type timedcache struct {
|
|||||||
func (c *timedcache) Add(k any, x any, d time.Duration) error {
|
func (c *timedcache) Add(k any, x any, d time.Duration) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
_, found := c.get(k)
|
// _, found := c.get(k)
|
||||||
if found {
|
// if found {
|
||||||
return fmt.Errorf("Item %s already exists", k)
|
// return fmt.Errorf("Item %s already exists", k)
|
||||||
}
|
// }
|
||||||
c.set(k, x, d)
|
c.set(k, x, d)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
19
server/pkg/otp/otp.go
Normal file
19
server/pkg/otp/otp.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package otp
|
||||||
|
|
||||||
|
import (
|
||||||
|
otp_t "github.com/pquerna/otp"
|
||||||
|
totp_t "github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateOpts totp_t.GenerateOpts
|
||||||
|
|
||||||
|
func NewTOTP(opt GenerateOpts) (*otp_t.Key, error) {
|
||||||
|
return totp_t.Generate(totp_t.GenerateOpts(opt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validate(code string, secret string) bool {
|
||||||
|
if secret == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return totp_t.Validate(code, secret)
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ func connRedis() *redis.Client {
|
|||||||
// 测试连接
|
// 测试连接
|
||||||
_, e := rdb.Ping(context.TODO()).Result()
|
_, e := rdb.Ping(context.TODO()).Result()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
global.Log.Panic(fmt.Sprintf("连接redis失败! [%s:%d]", redisConf.Host, redisConf.Port))
|
global.Log.Panic(fmt.Sprintf("连接redis失败! [%s:%d][%s]", redisConf.Host, redisConf.Port, e.Error()))
|
||||||
}
|
}
|
||||||
return rdb
|
return rdb
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ func GetRsaPublicKey() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
cache.SetStr(publicKeyK, publicKey)
|
cache.SetStr(publicKeyK, publicKey, -1)
|
||||||
cache.SetStr(privateKeyK, privateKey)
|
cache.SetStr(privateKeyK, privateKey, -1)
|
||||||
return publicKey, nil
|
return publicKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ func GetRsaPrivateKey() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
cache.SetStr(publicKeyK, publicKey)
|
cache.SetStr(publicKeyK, publicKey, -1)
|
||||||
cache.SetStr(privateKeyK, privateKey)
|
cache.SetStr(privateKeyK, privateKey, -1)
|
||||||
return privateKey, nil
|
return privateKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user