feat: 添加二步验证相关

This commit is contained in:
sunxiaolong
2024-07-13 13:05:32 +08:00
parent fbd254bdb8
commit 7112529a9c
11 changed files with 375 additions and 60 deletions

View File

@@ -12,8 +12,7 @@ server:
steps:
- name: build new image
commands:
- docker compose -f ./deploy/docker-compose.yml build --no-cache
- USE_PROXY=1 docker compose -f ./deploy/docker-compose.yml build --no-cache
- name: clean old continaer
commands:
- docker container inspect kami_frontend &> /dev/null && docker container rm -f kami_frontend
@@ -55,7 +54,7 @@ clone:
steps:
- name: build new image
commands:
- docker compose -f ./deploy/docker-compose.yml build --no-cache
- USE_PROXY=0 docker compose -f ./deploy/docker-compose.yml build --no-cache
- name: clean old continaer
commands:
- docker container inspect kami_frontend &> /dev/null && docker container rm -f kami_frontend

View File

@@ -2,7 +2,14 @@ FROM node:20 as builder
WORKDIR /build
COPY . .
RUN npm i -g nrm && nrm use taobao && npm install -g npm@latest pnpm@9.4.0 && pnpm i && pnpm build
# 定义参数
ARG USE_PROXY
# 根据USE_PROXY参数设置环境变量
RUN if [ "$USE_PROXY" = "1" ]; then \
npm i -g nrm && nrm use taobao; \
fi \
&& npm install -g npm@latest pnpm@9.4.0 && pnpm i && pnpm build
FROM nginx:latest

View File

@@ -3,6 +3,8 @@ services:
build:
context: ../.
dockerfile: ./deploy/Dockerfile
args:
- USE_PROXY=${USE_PROXY}
container_name: kami_frontend
image: kami_frontend:0.11
restart: always

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" type="image/x-icon"
href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico">
<!-- <link rel="shortcut icon" type="image/x-icon"
href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico"> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>卡销终端解决方案</title>
<title>卡销终端解决方案(核销端)</title>
</head>
<body>

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import qs from 'query-string';
import type { RouteRecordNormalized } from 'vue-router';
import type { UserState } from '@/store/modules/user/types';
import { encryptWithBase64 } from '@/utils/encrypt';
@@ -7,6 +8,7 @@ import type { PaymentRecord } from './sys-user-payment';
export interface LoginData {
username: string;
password: string;
totpCode: string;
verifyCode: string;
verifyKey: string;
}
@@ -40,10 +42,12 @@ export function getCaptchaAPI() {
export function changePassword(data: {
oldPassword: string;
newPassword: string;
totpCode: string;
}) {
data = {
oldPassword: encryptWithBase64(data.oldPassword),
newPassword: encryptWithBase64(data.newPassword)
newPassword: encryptWithBase64(data.newPassword),
totpCode: data.totpCode
};
return axios.put('/user/changePwd', data);
}
@@ -56,3 +60,39 @@ export interface statisticsRecord {
export function queryStatistics() {
return axios.get<statisticsRecord>('/sysUser/statistics');
}
// 获取当前账号是否开启了二步验证
export function queryTotpStatus() {
return axios.get<{
status: boolean;
image?: string;
otpSecret?: string;
otpKey?: string;
}>('/user/totp/status');
}
// 设置两步验证
export function setTotp(data: {
code: string;
password: string;
otpKey: string;
otpSecret: string;
}) {
data.password = encryptWithBase64(data.password);
return axios.post('/user/totp/set', data);
}
// 查看两步验证图像
export function getTotpImage(params: { code: string }) {
return axios.get<{ image: string }>('/user/totp/image', {
params,
paramsSerializer: obj => {
return qs.stringify(obj);
}
});
}
// 重置二步验证
export function resetTotp(data: { code: string }) {
return axios.post('/user/totp/reset', data);
}

View File

@@ -2,27 +2,19 @@ import CryptoJS from 'crypto-js';
const CRYPTOJSKEY = 'thisis32bitlongpassphraseimusing';
const iv = '1234567890123456';
const options = {
iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
};
export function encryptWithBase64(ciphertext: string) {
const options = {
iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
};
const key = CryptoJS.enc.Utf8.parse(CRYPTOJSKEY);
const encryptedData = CryptoJS.AES.encrypt(ciphertext, key, options);
const encryptedBase64Str = encryptedData.toString();
return encryptedBase64Str;
return encryptedData.toString();
}
export function decryptWithBase64(ciphertext: string) {
const options = {
iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
};
const key = CryptoJS.enc.Utf8.parse(CRYPTOJSKEY);
const decryptData = CryptoJS.AES.decrypt(ciphertext, key, options);
const encryptedBase64Str = decryptData.toString();
return encryptedBase64Str;
const decryptedData = CryptoJS.AES.decrypt(ciphertext, key, options);
return decryptedData.toString(CryptoJS.enc.Utf8);
}

View File

@@ -32,6 +32,16 @@
</template>
</a-input-password>
</a-form-item>
<a-form-item field="totpCode" validate-trigger="blur" hide-label>
<a-input
v-model="form.totpCode"
placeholder="请输入两步验证(如果未设置不需要填写)"
>
<template #prefix>
<icon-robot />
</template>
</a-input>
</a-form-item>
<a-form-item field="capcha" validate-trigger="blur" hide-label>
<a-row justify="space-around" :gutter="8">
<a-col :span="16">
@@ -95,7 +105,7 @@
</a-tabs>
<a-button
type="primary"
style="margin: 32px 0 6px"
style="margin: 20px 0 6px"
long
:loading="loading"
@click="handleSubmit"
@@ -133,11 +143,13 @@ import { getCaptchaAPI } from '@/api/user';
import { pick } from 'lodash';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import { decryptWithBase64, encryptWithBase64 } from '@/utils/encrypt';
import { isUndefined } from '@/utils/is';
const router = useRouter();
const codeDisabled = ref(false);
// const codeDisabled = ref(false);
const userStore = useUserStore();
const codeText = ref('获取验证码');
// const codeText = ref('获取验证码');
const formRef = ref();
const tabActiveKey = ref('1');
const state = reactive({
@@ -147,16 +159,22 @@ const state = reactive({
});
const { loading, setLoading } = useLoading();
const loginConfig = useStorage('login-config', {
rememberPassword: true,
username: '', // 演示默认值
password: '' // 演示密码
userInfo: ''
});
const form = reactive({
username: loginConfig.value.username,
password: loginConfig.value.password,
// phone: '',
username:
!isUndefined(loginConfig.value.userInfo) &&
loginConfig.value.userInfo !== ''
? JSON.parse(decryptWithBase64(loginConfig.value.userInfo)).username
: '',
password:
!isUndefined(loginConfig.value.userInfo) &&
loginConfig.value.userInfo !== ''
? JSON.parse(decryptWithBase64(loginConfig.value.userInfo)).password
: '',
totpCode: '',
captcha: ''
// agreement: false
});
@@ -172,7 +190,8 @@ const rules = {
// /^(?![\d]+$)(?![a-z]+$)(?![A-Z]+$)(?![~!@#$%^&*.]+$)[\da-zA-z~!@#$%^&*.]{6,32}$/,
// message: '密码格式不正确'
// }
]
],
totpCode: [{ match: /^\d{6}$/, message: '请输入正确的两步验证码(6位数字)' }]
// phone: [
// { required: true, message: '请输入手机号' },
// { length: 11, message: '手机号格式不正确' }
@@ -186,7 +205,6 @@ const handleSubmit = () => {
.validateField(['username', 'password', 'captcha'])
.then(async res => {
if (res) {
getCaptcha();
return;
}
// if (!form.agreement) {
@@ -194,10 +212,16 @@ const handleSubmit = () => {
// }
setLoading(true);
try {
const userInfoForm = pick(form, ['username', 'captcha', 'password']);
const userInfoForm = pick(form, [
'username',
'captcha',
'password',
'totpCode'
]);
await userStore.login({
verifyKey: state.captchaKey,
verifyCode: userInfoForm.captcha,
totpCode: userInfoForm.totpCode,
username: userInfoForm.username,
password: userInfoForm.password
});
@@ -209,17 +233,26 @@ const handleSubmit = () => {
}
});
Message.success('登录成功');
const { rememberPassword } = loginConfig.value;
const { username, password } = userInfoForm;
// 实际生产环境需要进行加密存储。
loginConfig.value.username = rememberPassword ? username : '';
loginConfig.value.password = rememberPassword ? password : '';
console.log(
JSON.stringify({
username: userInfoForm.username,
password: userInfoForm.password
})
);
// const { username, password } = userInfoForm;
loginConfig.value.userInfo = encryptWithBase64(
JSON.stringify({
username: userInfoForm.username,
password: userInfoForm.password
})
);
// loginConfig.value.rememberPassword = rememberPassword;
} finally {
getCaptcha();
setLoading(false);
}
});
}
if (tabActiveKey.value === '2') {
formRef.value.validateField(['username', 'captcha']).then(res => {
if (res) return;
@@ -229,21 +262,17 @@ const handleSubmit = () => {
});
}
};
const setRememberPassword = (value: boolean) => {
loginConfig.value.rememberPassword = value;
};
const getCaptcha = async () => {
const res = await getCaptchaAPI();
state.captchaUrl = res.data.img;
state.captchaKey = res.data.key;
};
onMounted(() => {
getCaptcha();
});
// const { start } = useCountDown({
// initValue: 59,
// onEnd: () => {
@@ -318,7 +347,7 @@ onMounted(() => {
}
}
:deep(.arco-tabs-content) {
height: 192px;
}
// :deep(.arco-tabs-content) {
// height: 192px;
// }
</style>

View File

@@ -0,0 +1,8 @@
.general-card {
padding-bottom: 1rem;
.totp-inner-card {
padding: 0 6rem;
text-align: center;
}
}

View File

@@ -1,6 +1,11 @@
<template>
<a-card title="修改密码" class="general-card">
<a-form ref="formRef" :model="formData" :rules="rules">
<a-form
ref="formRef"
:model="formData"
:rules="rules"
style="padding: 0 6rem"
>
<a-form-item field="oldPassword" label="现密码" required>
<a-input-password v-model="formData.oldPassword" />
</a-form-item>
@@ -10,9 +15,14 @@
<a-form-item field="confirmPassword" label="确认密码" required>
<a-input-password v-model="formData.confirmPassword" />
</a-form-item>
<a-form-item>
<a-button html-type="submit" @click="onConform">确认</a-button>
<a-form-item
field="totpCode"
label="两步验证"
tooltip="如果设置了就需要填写"
>
<a-verification-code v-model="formData.totpCode" />
</a-form-item>
<a-button type="primary" long @click="onConform">确认</a-button>
</a-form>
</a-card>
</template>
@@ -25,9 +35,9 @@ import { reactive, ref } from 'vue';
const formData = reactive({
oldPassword: '',
password: '',
confirmPassword: ''
confirmPassword: '',
totpCode: ''
});
const rules: Record<string, FieldRule<any> | FieldRule<any>[]> = {
oldPassword: [{ required: true, message: '请输入现有密码' }],
password: [{ required: true, message: '请输入新密码' }],
@@ -42,7 +52,8 @@ const rules: Record<string, FieldRule<any> | FieldRule<any>[]> = {
}
}
}
]
],
totpCode: [{ match: /^\d{6}$/, message: '请输入正确的两步验证码(6位数字)' }]
};
const formRef = ref<FormInstance>();
@@ -52,11 +63,16 @@ const onConform = async () => {
if (errors) return;
await changePassword({
oldPassword: formData.oldPassword,
newPassword: formData.password
newPassword: formData.password,
totpCode: formData.totpCode
});
Message.success('密码修改成功');
});
};
</script>
<style scoped lang="less"></style>
<style lang="less" scoped>
.general-card {
padding-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,218 @@
import { getTotpImage, queryTotpStatus, resetTotp, setTotp } from '@/api/user';
import {
Card,
Form,
FormItem,
VerificationCode,
Image,
Button,
InputPassword,
type FormInstance,
Space
} from '@arco-design/web-vue';
import { defineComponent, onMounted, reactive, ref } from 'vue';
import './css/totp.less';
export const StepTwoForm = defineComponent({
emits: { submit: () => true },
setup(_, { emit }) {
const formData = reactive({
code: ''
});
const formRef = ref<FormInstance>();
const rules = {
code: [
{
required: true,
message: '验证码不能为空'
}
]
};
const state = reactive<{ showImage: boolean; image: string }>({
showImage: false,
image: ''
});
return () => (
<Space direction='vertical' fill>
{state.image ? (
<div>
<Image src={state.image} />
<Button
status='warning'
onClick={() => {
state.showImage = false;
state.image = '';
formData.code = '';
}}
long
>
</Button>
</div>
) : (
<div>
<Form ref={formRef} model={formData} rules={rules}>
<FormItem field='code' required label='验证码'>
<VerificationCode v-model={formData.code} />
</FormItem>
</Form>
<Button
status='success'
onClick={() => {
formRef.value.validate().then(res => {
if (res) return;
getTotpImage(formData).then(res => {
state.image = res.data.image;
state.showImage = true;
});
});
}}
long
>
</Button>
</div>
)}
<Button
status='danger'
onClick={() => {
formRef.value.validate().then(res => {
if (res) return;
resetTotp(formData).then(() => {
emit('submit');
});
});
}}
long
>
</Button>
</Space>
);
}
});
export const StepOneForm = defineComponent({
props: {
QrImage: {
type: String,
required: true
},
otpKey: {
type: String,
required: true
},
otpSecret: {
type: String,
required: true
}
},
emits: ['submit'],
setup(props, { emit }) {
const formData = reactive<{ password: string; code: string }>({
password: '',
code: ''
});
const formDataRef = ref<FormInstance>();
const rules = {
password: [
{
required: true,
message: '密码不能为空'
}
],
code: [
{
required: true,
message: '验证码不能为空'
}
]
};
return () => (
<div>
<Image src={props.QrImage} />
<Form ref={formDataRef} model={formData} rules={rules}>
<FormItem field='password' label='密码' required>
<InputPassword v-model={formData.password} />
</FormItem>
<FormItem field='code' required label='二步验证'>
<VerificationCode
class='totp-verification-code'
v-model={formData.code}
/>
</FormItem>
<FormItem>
<Button
type='primary'
long
onClick={() => {
formDataRef.value.validate().then(res => {
if (res) return;
setTotp({
otpSecret: props.otpSecret,
otpKey: props.otpKey,
...formData
}).then(() => {
emit('submit');
});
});
}}
>
</Button>
</FormItem>
</Form>
</div>
);
}
});
export default defineComponent({
setup() {
const state = reactive<{
totpStatus: boolean;
QrImage: string;
otpSecret: string;
otpKey: string;
}>({
totpStatus: false,
QrImage: '',
otpSecret: '',
otpKey: ''
});
const getTotpStatus = () => {
console.log('wobwdnysle');
queryTotpStatus().then(res => {
state.totpStatus = res.data.status;
if (!state.totpStatus) {
state.QrImage = res.data.image;
state.otpSecret = res.data.otpSecret;
state.otpKey = res.data.otpKey;
}
});
};
onMounted(() => {
getTotpStatus();
});
return () => (
<Card
class='general-card'
title='二步验证'
v-slots={{ extra: () => (state.totpStatus ? '已开启' : '未开启') }}
>
<div class='totp-inner-card'>
{state.totpStatus ? (
<StepTwoForm onSubmit={getTotpStatus} />
) : (
<StepOneForm
QrImage={state.QrImage}
otpKey={state.otpKey}
otpSecret={state.otpSecret}
onSubmit={getTotpStatus}
/>
)}
</div>
</Card>
);
}
});

View File

@@ -1,9 +1,12 @@
<template>
<div class="container">
<Breadcrumb :items="['用户相关', '用户设置']" />
<a-row>
<a-col :span="8">
<reset-password />
<a-row justify="center">
<a-col :span="12">
<a-space direction="vertical" fill>
<reset-password />
<totp />
</a-space>
</a-col>
</a-row>
</div>
@@ -11,4 +14,5 @@
<script setup lang="ts">
import ResetPassword from './components/reset-password.vue';
import Totp from './components/totp.tsx';
</script>