mirror of
https://github.com/1Panel-dev/MaxKB.git
synced 2025-12-25 17:22:55 +00:00
feat: enhance login settings with account lockout and permission messages
This commit is contained in:
parent
a7bb173cc1
commit
4fecd47904
|
|
@ -8865,4 +8865,13 @@ msgid "Super-humanoid: Lingfeibo Pro"
|
|||
msgstr ""
|
||||
|
||||
msgid "Super-humanoid: Lingyuyan Pro"
|
||||
msgstr ""
|
||||
|
||||
msgid "Login failed %s times, account will be locked, you have %s more chances !"
|
||||
msgstr ""
|
||||
|
||||
msgid "This account has been locked for %s minutes, please try again later"
|
||||
msgstr ""
|
||||
|
||||
msgid "User does not have permission to use API Key"
|
||||
msgstr ""
|
||||
|
|
@ -8991,4 +8991,13 @@ msgid "Super-humanoid: Lingfeibo Pro"
|
|||
msgstr "聆飞博"
|
||||
|
||||
msgid "Super-humanoid: Lingyuyan Pro"
|
||||
msgstr "聆玉言"
|
||||
msgstr "聆玉言"
|
||||
|
||||
msgid "Login failed %s times, account will be locked, you have %s more chances !"
|
||||
msgstr "登录失败 %s 次,账号将被锁定,您还有 %s 次机会!"
|
||||
|
||||
msgid "This account has been locked for %s minutes, please try again later"
|
||||
msgstr "该账号已被锁定 %s 分钟,请稍后再试"
|
||||
|
||||
msgid "User does not have permission to use API Key"
|
||||
msgstr "用户没有使用 API Key 的权限"
|
||||
|
|
@ -8991,4 +8991,13 @@ msgid "Super-humanoid: Lingfeibo Pro"
|
|||
msgstr "聆飛博"
|
||||
|
||||
msgid "Super-humanoid: Lingyuyan Pro"
|
||||
msgstr "聆玉言"
|
||||
msgstr "聆玉言"
|
||||
|
||||
msgid "Login failed %s times, account will be locked, you have %s more chances !"
|
||||
msgstr "登录失败 %s 次,账号将被锁定,您还有 %s 次机会!"
|
||||
|
||||
msgid "This account has been locked for %s minutes, please try again later"
|
||||
msgstr "該帳號已被鎖定 %s 分鐘,請稍後再試"
|
||||
|
||||
msgid "User does not have permission to use API Key"
|
||||
msgstr "使用者沒有使用 API Key 的權限"
|
||||
|
|
|
|||
|
|
@ -77,58 +77,134 @@ class LoginSerializer(serializers.Serializer):
|
|||
|
||||
@staticmethod
|
||||
def login(instance):
|
||||
# 解密数据
|
||||
username = instance.get("username", "")
|
||||
encryptedData = instance.get("encryptedData", "")
|
||||
if encryptedData:
|
||||
json_data = json.loads(decrypt(encryptedData))
|
||||
instance.update(json_data)
|
||||
encrypted_data = instance.get("encryptedData", "")
|
||||
|
||||
if encrypted_data:
|
||||
decrypted_data = json.loads(decrypt(encrypted_data))
|
||||
instance.update(decrypted_data)
|
||||
try:
|
||||
LoginRequest(data=instance).is_valid(raise_exception=True)
|
||||
except serializers.ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
record_login_fail(username)
|
||||
raise e
|
||||
auth_setting = LoginSerializer.get_auth_setting()
|
||||
raise AppApiException(500, str(e))
|
||||
|
||||
max_attempts = auth_setting.get("max_attempts", 1)
|
||||
password = instance.get("password")
|
||||
captcha = instance.get("captcha", "")
|
||||
|
||||
# 判断是否需要验证码
|
||||
need_captcha = False
|
||||
if max_attempts == -1:
|
||||
need_captcha = False
|
||||
elif max_attempts > 0:
|
||||
fail_count = cache.get(system_get_key(f'system_{username}'), version=system_version) or 0
|
||||
need_captcha = fail_count >= max_attempts
|
||||
# 获取认证配置
|
||||
auth_setting = LoginSerializer.get_auth_setting()
|
||||
max_attempts = auth_setting.get("max_attempts", 1)
|
||||
failed_attempts = auth_setting.get("failed_attempts", 5)
|
||||
lock_time = auth_setting.get("lock_time", 10)
|
||||
|
||||
if need_captcha:
|
||||
if not captcha:
|
||||
raise AppApiException(1005, _("Captcha is required"))
|
||||
# 检查许可证有效性
|
||||
license_validator = DatabaseModelManage.get_model('license_is_valid') or (lambda: False)
|
||||
is_license_valid = license_validator() if license_validator() is not None else False
|
||||
|
||||
captcha_cache = cache.get(
|
||||
Cache_Version.CAPTCHA.get_key(captcha=f"system_{username}"),
|
||||
version=Cache_Version.CAPTCHA.get_version()
|
||||
)
|
||||
if captcha_cache is None or captcha.lower() != captcha_cache:
|
||||
raise AppApiException(1005, _("Captcha code error or expiration"))
|
||||
if is_license_valid:
|
||||
# 检查账户是否被锁定
|
||||
if LoginSerializer._is_account_locked(username):
|
||||
raise AppApiException(
|
||||
1005,
|
||||
_("This account has been locked for %s minutes, please try again later") % lock_time
|
||||
)
|
||||
|
||||
user = QuerySet(User).filter(username=username, password=password_encrypt(password)).first()
|
||||
if user is None:
|
||||
record_login_fail(username)
|
||||
# 验证验证码
|
||||
if LoginSerializer._need_captcha(username, max_attempts):
|
||||
LoginSerializer._validate_captcha(username, captcha)
|
||||
|
||||
# 验证用户凭据
|
||||
user = QuerySet(User).filter(
|
||||
username=username,
|
||||
password=password_encrypt(password)
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
LoginSerializer._handle_failed_login(username, is_license_valid, failed_attempts, lock_time)
|
||||
raise AppApiException(500, _('The username or password is incorrect'))
|
||||
|
||||
if not user.is_active:
|
||||
record_login_fail(username)
|
||||
raise AppApiException(1005, _("The user has been disabled, please contact the administrator!"))
|
||||
|
||||
# 清除失败计数并生成令牌
|
||||
cache.delete(system_get_key(f'system_{username}'), version=system_version)
|
||||
token = signing.dumps({'username': user.username,
|
||||
'id': str(user.id),
|
||||
'email': user.email,
|
||||
'type': AuthenticationType.SYSTEM_USER.value})
|
||||
cache.delete(system_get_key(f'system_{username}_lock'), version=system_version)
|
||||
token = signing.dumps({
|
||||
'username': user.username,
|
||||
'id': str(user.id),
|
||||
'email': user.email,
|
||||
'type': AuthenticationType.SYSTEM_USER.value
|
||||
})
|
||||
|
||||
version, get_key = Cache_Version.TOKEN.value
|
||||
timeout = CONFIG.get_session_timeout()
|
||||
cache.set(get_key(token), user, timeout=timeout, version=version)
|
||||
|
||||
return {'token': token}
|
||||
|
||||
@staticmethod
|
||||
def _is_account_locked(username: str) -> bool:
|
||||
"""检查账户是否被锁定"""
|
||||
lock_cache = cache.get(system_get_key(f'system_{username}_lock'), version=system_version)
|
||||
return bool(lock_cache)
|
||||
|
||||
@staticmethod
|
||||
def _need_captcha(username: str, max_attempts: int) -> bool:
|
||||
"""判断是否需要验证码"""
|
||||
if max_attempts == -1:
|
||||
return False
|
||||
elif max_attempts > 0:
|
||||
fail_count = cache.get(system_get_key(f'system_{username}'), version=system_version) or 0
|
||||
return fail_count >= max_attempts
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_captcha(username: str, captcha: str) -> None:
|
||||
"""验证验证码"""
|
||||
if not captcha:
|
||||
raise AppApiException(1005, _("Captcha is required"))
|
||||
|
||||
captcha_cache = cache.get(
|
||||
Cache_Version.CAPTCHA.get_key(captcha=f"system_{username}"),
|
||||
version=Cache_Version.CAPTCHA.get_version()
|
||||
)
|
||||
|
||||
if captcha_cache is None or captcha.lower() != captcha_cache:
|
||||
raise AppApiException(1005, _("Captcha code error or expiration"))
|
||||
|
||||
@staticmethod
|
||||
def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts: int, lock_time: int) -> None:
|
||||
"""处理登录失败"""
|
||||
record_login_fail(username)
|
||||
|
||||
if not is_license_valid or failed_attempts <= 0:
|
||||
return
|
||||
|
||||
fail_count = cache.get(system_get_key(f'system_{username}'), version=system_version) or 0
|
||||
remain_attempts = failed_attempts - fail_count
|
||||
|
||||
if remain_attempts > 0:
|
||||
raise AppApiException(
|
||||
1005,
|
||||
_("Login failed %s times, account will be locked, you have %s more chances !") % (
|
||||
failed_attempts, remain_attempts
|
||||
)
|
||||
)
|
||||
elif remain_attempts == 0:
|
||||
cache.set(
|
||||
system_get_key(f'system_{username}_lock'),
|
||||
1,
|
||||
timeout=lock_time * 60,
|
||||
version=system_version
|
||||
)
|
||||
raise AppApiException(
|
||||
1005,
|
||||
_("This account has been locked for %s minutes, please try again later") % lock_time
|
||||
)
|
||||
|
||||
|
||||
class CaptchaResponse(serializers.Serializer):
|
||||
"""
|
||||
|
|
@ -171,7 +247,6 @@ class CaptchaSerializer(serializers.Serializer):
|
|||
fail_count = cache.get(system_get_key(f'{type}_{username}'), version=system_version) or 0
|
||||
need_captcha = fail_count >= max_attempts
|
||||
|
||||
|
||||
return CaptchaSerializer._generate_captcha_if_needed(username, type, need_captcha)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -159,4 +159,5 @@ export default {
|
|||
setting: 'Login Setting',
|
||||
failedTip: 'Next, lock the account',
|
||||
minute: 'Minutes',
|
||||
third_party_user_default_role: 'Default Role Assignment for Third-party Users',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,4 +160,5 @@ export default {
|
|||
display_codeTip: '值为-1时,不显示验证码',
|
||||
time: '次',
|
||||
setting: '登录设置',
|
||||
third_party_user_default_role: '第三方用户默认角色分配',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,4 +160,5 @@ export default {
|
|||
minute: '分鐘',
|
||||
time: '次',
|
||||
setting: '登录設置',
|
||||
third_party_user_default_role: '第三方用戶預設角色分配',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
prop="default_value"
|
||||
style="padding-top: 16px"
|
||||
>
|
||||
<el-radio-group v-model="form.default_value" class="radio-group">
|
||||
<el-radio-group v-model="form.default_value" class="radio-group"
|
||||
style="margin-left: 10px;">
|
||||
<el-radio
|
||||
v-for="method in loginMethods"
|
||||
:key="method.value"
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
]"
|
||||
prop="max_attempts"
|
||||
>
|
||||
<el-row :gutter="16">
|
||||
<el-row :gutter="16" style="margin-left: 10px;">
|
||||
<el-col :span="24">
|
||||
<span style="font-size: 13px;">
|
||||
{{ $t('views.system.loginFailed') }}
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
:max="10"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
@change="onMaxAttemptsChange"
|
||||
/>
|
||||
<span style="margin-left: 8px; font-size: 13px;">
|
||||
{{ $t('views.system.loginFailedMessage') }}
|
||||
|
|
@ -72,25 +74,78 @@
|
|||
:max="10"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
@change="onFailedAttemptsChange"
|
||||
/>
|
||||
<span style="margin-left: 8px; font-size: 13px;">
|
||||
{{ $t('views.system.loginFailedMessage') }}
|
||||
{{ $t('views.system.failedTip') }}
|
||||
</span>
|
||||
<el-input-number
|
||||
style="margin-left: 8px;"
|
||||
v-model="form.lock_time"
|
||||
:min="-1"
|
||||
:max="10"
|
||||
:min="1"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span style="margin-left: 8px; font-size: 13px;">
|
||||
分钟
|
||||
{{ $t('views.system.minute') }}
|
||||
</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('views.system.third_party_user_default_role')"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: $t('views.system.thirdPartyUserDefaultRoleRequired'),
|
||||
trigger: 'change',
|
||||
},
|
||||
]">
|
||||
<el-row :gutter="16" style="margin-left: 10px;">
|
||||
<el-col :span="12">
|
||||
<div style="display: flex; align-items: center; gap: 8px; min-width: 0;">
|
||||
<span style="font-size: 13px; white-space: nowrap;">
|
||||
{{ $t('views.role.member.role') }}
|
||||
</span>
|
||||
<el-select
|
||||
v-model="form.role_id"
|
||||
:placeholder="`${$t('common.selectPlaceholder')}${$t('views.role.member.role')}`"
|
||||
style="flex: 1; min-width: 180px;"
|
||||
@change="handleRoleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.name"
|
||||
:value="role.id"
|
||||
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="user.isEE() && showWorkspaceSelector">
|
||||
<div style="display: flex; align-items: center; gap: 8px; min-width: 0;">
|
||||
<span style="font-size: 13px; white-space: nowrap;">
|
||||
{{ $t('views.role.member.workspace') }}
|
||||
</span>
|
||||
<el-select
|
||||
v-model="form.workspace_id"
|
||||
:placeholder="`${$t('common.selectPlaceholder')}${$t('views.role.member.workspace')}`"
|
||||
style="flex: 1; min-width: 180px;"
|
||||
>
|
||||
<el-option
|
||||
v-for="workspace in workspaceOptions"
|
||||
:key="workspace.id"
|
||||
:label="workspace.name"
|
||||
:value="workspace.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="margin-top:16px;">
|
||||
<span
|
||||
|
|
@ -99,10 +154,11 @@
|
|||
"
|
||||
class="mr-12"
|
||||
>
|
||||
<el-button @click="submit(authFormRef)" type="primary" :disabled="loading">
|
||||
{{ $t('common.save') }}
|
||||
</el-button>
|
||||
</span>
|
||||
<!-- 直接调用 submit,不传参 -->
|
||||
<el-button @click="submit" type="primary" :disabled="loading">
|
||||
{{ $t('common.save') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
|
@ -110,58 +166,147 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, reactive} from "vue";
|
||||
import {ref, onMounted, computed} from "vue";
|
||||
import {ComplexPermission} from "@/utils/permission/type";
|
||||
import {PermissionConst, RoleConst} from "@/utils/permission/data";
|
||||
import type {FormInstance, FormRules} from 'element-plus';
|
||||
import type {FormInstance} from 'element-plus';
|
||||
import {t} from "@/locales";
|
||||
import authApi from "@/api/system-settings/auth-setting.ts";
|
||||
import {MsgSuccess} from "@/utils/message.ts";
|
||||
import WorkspaceApi from "@/api/workspace/workspace.ts";
|
||||
import useStore from "@/stores";
|
||||
|
||||
const loginMethods = ref<Array<{ label: string; value: string }>>([]);
|
||||
const loading = ref(false);
|
||||
const authFormRef = ref<FormInstance>();
|
||||
|
||||
// 明确允许 null,避免未挂载时访问出错
|
||||
const authFormRef = ref<FormInstance | null>(null);
|
||||
|
||||
const form = ref<any>({
|
||||
default_value: 'LOCAL',
|
||||
max_attempts: 1,
|
||||
failed_attempts: 5,
|
||||
lock_time: 10,
|
||||
role_id: 'USER',
|
||||
workspace_id: 'default',
|
||||
})
|
||||
|
||||
const submit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const params = {
|
||||
default_value: form.value.default_value,
|
||||
max_attempts: form.value.max_attempts,
|
||||
};
|
||||
authApi.putLoginSetting(params, loading).then((res) => {
|
||||
MsgSuccess(t('common.saveSuccess'))
|
||||
})
|
||||
} else {
|
||||
console.log('error submit!', fields);
|
||||
}
|
||||
});
|
||||
const normalizeInputValue = (val: number | null): number => {
|
||||
// 若输入为空或无法转换为有效数字,默认设为 1
|
||||
let normalizedVal = typeof val === 'number' ? Math.trunc(val) : NaN;
|
||||
if (!Number.isFinite(normalizedVal)) {
|
||||
normalizedVal = 1;
|
||||
}
|
||||
|
||||
if (normalizedVal === 0) {
|
||||
normalizedVal = 1;
|
||||
} else if (normalizedVal < -1) {
|
||||
normalizedVal = -1;
|
||||
}
|
||||
|
||||
return normalizedVal;
|
||||
};
|
||||
|
||||
const onFailedAttemptsChange = (val: number | null) => {
|
||||
form.value.failed_attempts = normalizeInputValue(val);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
authApi.getLoginSetting().then((res) => {
|
||||
if (Object.keys(res.data).length > 0) {
|
||||
form.value = res.data;
|
||||
if (!form.value.failed_attempts) {
|
||||
form.value.failed_attempts = 5;
|
||||
}
|
||||
if (!form.value.lock_time) {
|
||||
form.value.lock_time = 10;
|
||||
}
|
||||
loginMethods.value = res.data.auth_types
|
||||
const onMaxAttemptsChange = (val: number | null) => {
|
||||
form.value.max_attempts = normalizeInputValue(val);
|
||||
};
|
||||
|
||||
// 提交:使用 authFormRef.value.validate() 的 Promise 风格,并保证 loading 在 finally 中恢复
|
||||
const submit = async () => {
|
||||
const formRef = authFormRef.value;
|
||||
if (!formRef) return;
|
||||
try {
|
||||
await formRef.validate();
|
||||
loading.value = true;
|
||||
const params = {
|
||||
default_value: form.value.default_value,
|
||||
max_attempts: form.value.max_attempts,
|
||||
failed_attempts: form.value.failed_attempts,
|
||||
lock_time: form.value.lock_time,
|
||||
role_id: form.value.role_id,
|
||||
workspace_id: form.value.workspace_id,
|
||||
};
|
||||
await authApi.putLoginSetting(params);
|
||||
MsgSuccess(t('common.saveSuccess'));
|
||||
} catch (err) {
|
||||
// 验证或请求失败:按需处理,避免未捕获异常
|
||||
// console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = ref<Array<{ id: string; name: string, type?: string }>>([]);
|
||||
const workspaceOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const {user} = useStore();
|
||||
const selectedRoleType = ref<string>(''); // 存储选中角色类型,用于控制 workspace 显示
|
||||
const showWorkspaceSelector = computed(() => selectedRoleType.value !== 'ADMIN');
|
||||
|
||||
// 当角色变更时更新 selectedRoleType
|
||||
const handleRoleChange = (roleId: string) => {
|
||||
const selectedRole = roleOptions.value.find(role => role.id === roleId);
|
||||
selectedRoleType.value = selectedRole?.type || '';
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const isEE = typeof user?.isEE === 'function' ? user.isEE() : false;
|
||||
|
||||
// 并行请求:角色列表 + 登录设置;若为 EE 同时请求 workspace 列表
|
||||
const roleP = WorkspaceApi.getWorkspaceRoleList().then(r => r).catch(() => ({data: []}));
|
||||
const settingP = authApi.getLoginSetting().then(r => r).catch(() => ({data: {}}));
|
||||
const tasks: Promise<any>[] = [roleP, settingP];
|
||||
if (isEE) {
|
||||
tasks.push(WorkspaceApi.getWorkspaceList().then(r => r).catch(() => ({data: []})));
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
const roleRes = results[0] ?? {data: []};
|
||||
const settingRes = results[1] ?? {data: {}};
|
||||
const workspaceRes = isEE ? results[2] ?? {data: []} : null;
|
||||
|
||||
// 处理角色列表(尽早回显)
|
||||
const rolesData = Array.isArray(roleRes?.data) ? roleRes.data : [];
|
||||
roleOptions.value = rolesData.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type
|
||||
}));
|
||||
|
||||
// 处理 setting(合并默认值,避免访问未定义)
|
||||
const data = settingRes?.data ?? {};
|
||||
form.value = {
|
||||
...form.value,
|
||||
...data,
|
||||
failed_attempts: data.failed_attempts ?? form.value.failed_attempts ?? 5,
|
||||
lock_time: data.lock_time ?? form.value.lock_time ?? 10,
|
||||
role_id: data.role_id ?? form.value.role_id ?? 'USER',
|
||||
workspace_id: data.workspace_id ?? form.value.workspace_id ?? 'default'
|
||||
};
|
||||
loginMethods.value = Array.isArray(data.auth_types) ? data.auth_types : [];
|
||||
|
||||
// 处理 workspace 列表(如果需要)
|
||||
if (isEE && workspaceRes) {
|
||||
const wks = Array.isArray(workspaceRes.data) ? workspaceRes.data : [];
|
||||
workspaceOptions.value = wks.map((item: any) => ({id: item.id, name: item.name}));
|
||||
}
|
||||
|
||||
// 初始化 selectedRoleType(基于当前回显的 role_id 与已加载的 roleOptions)
|
||||
const initRole = roleOptions.value.find(r => r.id === form.value.role_id);
|
||||
selectedRoleType.value = initRole?.type || '';
|
||||
|
||||
} catch (e) {
|
||||
// overall error, 保持默认回显
|
||||
// console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
Loading…
Reference in New Issue