feat: enhance login settings with account lockout and permission messages

This commit is contained in:
wxg0103 2025-12-19 14:16:17 +08:00
parent a7bb173cc1
commit 4fecd47904
8 changed files with 326 additions and 76 deletions

View File

@ -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 ""

View File

@ -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 的权限"

View File

@ -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 的權限"

View File

@ -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

View File

@ -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',
}

View File

@ -160,4 +160,5 @@ export default {
display_codeTip: '值为-1时不显示验证码',
time: '次',
setting: '登录设置',
third_party_user_default_role: '第三方用户默认角色分配',
}

View File

@ -160,4 +160,5 @@ export default {
minute: '分鐘',
time: '次',
setting: '登录設置',
third_party_user_default_role: '第三方用戶預設角色分配',
}

View File

@ -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>