fix26: полный прод-фикс auth/jwt, import-export peer и frontend flow

This commit is contained in:
2026-05-09 01:18:13 +05:00
parent 7a64d76d08
commit c13841b61a
19 changed files with 390 additions and 90 deletions
+45 -15
View File
@@ -4,8 +4,10 @@ import (
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/entity"
"hy2xs-admin/util" "hy2xs-admin/util"
"os" "os"
"time"
) )
var resetCmd = &cobra.Command{ var resetCmd = &cobra.Command{
@@ -30,25 +32,53 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
if err = dao.InitSqliteDB(); err != nil { if err = dao.InitSql(""); err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
admin, err := dao.GetAdminUser("id = ?", 1) admin, err := dao.GetAdminUser("1 = 1")
nowMs := time.Now().UnixMilli()
if err != nil { if err != nil {
fmt.Println(err.Error()) tokenVersion := int64(1)
os.Exit(1) status := int64(1)
} forcePasswordChange := int64(1)
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{ passwordChangedAt := nowMs
"username": username, hash, hashErr := util.HashPassword(password)
"password_hash": func() string { if hashErr != nil {
hash, _ := util.HashPassword(password) fmt.Println(hashErr.Error())
return hash os.Exit(1)
}(), }
"force_password_change": 1, adminUser := entity.AdminUser{
}); err != nil { Username: &username,
fmt.Println(err.Error()) PasswordHash: &hash,
os.Exit(1) Status: &status,
ForcePasswordChange: &forcePasswordChange,
PasswordChangedAt: &passwordChangedAt,
TokenVersion: &tokenVersion,
}
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
fmt.Println(saveErr.Error())
os.Exit(1)
}
} else {
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion + 1
}
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"password_hash": func() string {
hash, _ := util.HashPassword(password)
return hash
}(),
"force_password_change": 1,
"password_changed_at": nowMs,
"token_version": tokenVersion,
"status": 1,
}); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
} }
if err = dao.CloseSqliteDB(); err != nil { if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
+2 -2
View File
@@ -2,6 +2,7 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
@@ -27,8 +28,7 @@ func Hysteria2Auth(c *gin.Context) {
// Обновление времени последнего подключения // Обновление времени последнего подключения
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
if err = service.UpdatePeerLastConnectionAt(id, now); err != nil { if err = service.UpdatePeerLastConnectionAt(id, now); err != nil {
vo.Fail(err.Error(), c) logrus.WithError(err).Warnf("failed to update peer last_connection_at: peer_id=%d", id)
return
} }
vo.Hysteria2AuthSuccess(username, c) vo.Hysteria2AuthSuccess(username, c)
} }
+4 -4
View File
@@ -10,9 +10,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util" "hy2xs-admin/util"
@@ -147,12 +147,12 @@ func ImportPeer(c *gin.Context) {
vo.Fail("json file read err", c) vo.Fail("json file read err", c)
return return
} }
var accounts []entity.Account var peerExports []bo.PeerExport
if err = json.Unmarshal(content, &accounts); err != nil { if err = json.Unmarshal(content, &peerExports); err != nil {
vo.Fail("content Unmarshal err", c) vo.Fail("content Unmarshal err", c)
return return
} }
if err = service.UpsertPeerLegacy(accounts); err != nil { if err = service.UpsertPeerExport(peerExports); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
+25 -20
View File
@@ -101,15 +101,19 @@ func ensureSecureBootstrapAdmin() error {
if adminUser == "" { if adminUser == "" {
adminUser = "hy2xsadmin" adminUser = "hy2xsadmin"
} }
if _, err := GetAdminUser("username = ?", adminUser); err == nil {
return nil
}
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
passwordGenerated := false
if adminPassword == "" { if adminPassword == "" {
password, pwdErr := util.RandomString(18) password, pwdErr := util.RandomString(18)
if pwdErr != nil { if pwdErr != nil {
return pwdErr return pwdErr
} }
adminPassword = password adminPassword = password
logrus.Warnf("Initial admin username: %s", adminUser) passwordGenerated = true
logrus.Warnf("Initial admin password: %s", adminPassword)
} }
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1) status := int64(1)
@@ -120,25 +124,21 @@ func ensureSecureBootstrapAdmin() error {
return hashErr return hashErr
} }
admin, err := GetAdminUser("username = ?", adminUser) username := adminUser
if err != nil { account := entity.AdminUser{
username := adminUser Username: &username,
account := entity.AdminUser{ PasswordHash: &hash,
Username: &username, Status: &status,
PasswordHash: &hash, TokenVersion: &tokenVersion,
Status: &status, PasswordChangedAt: &passwordChangedAt,
TokenVersion: &tokenVersion, ForcePasswordChange: func() *int64 { v := int64(forcePasswordChange); return &v }(),
PasswordChangedAt: &passwordChangedAt,
ForcePasswordChange: func() *int64 { v := int64(forcePasswordChange); return &v }(),
}
if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr
}
return nil
} }
if _, saveErr := SaveAdminUser(account); saveErr != nil {
if admin.PasswordHash == nil { return saveErr
return nil }
if passwordGenerated {
logrus.Warnf("Initial admin username: %s", adminUser)
logrus.Warnf("Initial admin password: %s", adminPassword)
} }
return nil return nil
} }
@@ -581,6 +581,11 @@ func ensureTrafficStatsSecret() error {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil return nil
} }
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret})
} }
secret, secErr := util.RandomString(32) secret, secErr := util.RandomString(32)
if secErr != nil { if secErr != nil {
+22 -1
View File
@@ -1,6 +1,12 @@
import request from "@/utils/request"; import request from "@/utils/request";
import { AxiosPromise } from "axios"; import { AxiosPromise } from "axios";
import { AdminInfo, AdminLoginDto, AdminLoginVo } from "./types"; import {
AdminChangePasswordDto,
AdminInfo,
AdminLoginDto,
AdminLoginVo,
AdminSecurityVo,
} from "./types";
export function loginApi(data: AdminLoginDto): AxiosPromise<AdminLoginVo> { export function loginApi(data: AdminLoginDto): AxiosPromise<AdminLoginVo> {
return request({ return request({
@@ -16,3 +22,18 @@ export function getAdminInfoApi(): AxiosPromise<AdminInfo> {
method: "get", method: "get",
}); });
} }
export function adminChangePasswordApi(data: AdminChangePasswordDto): AxiosPromise {
return request({
url: "/admin/change-password",
method: "post",
data,
});
}
export function adminSecurityApi(): AxiosPromise<AdminSecurityVo> {
return request({
url: "/admin/security",
method: "get",
});
}
+11
View File
@@ -6,11 +6,22 @@ export interface AdminLoginDto {
export interface AdminLoginVo { export interface AdminLoginVo {
accessToken: string; accessToken: string;
tokenType: string; tokenType: string;
forcePasswordChange: boolean;
} }
export interface AdminInfo { export interface AdminInfo {
id: number; id: number;
username: string; username: string;
roles: string[]; roles: string[];
forcePasswordChange: boolean;
}
export interface AdminChangePasswordDto {
oldPassword: string;
newPassword: string;
}
export interface AdminSecurityVo {
forcePasswordChange: boolean;
} }
+14 -1
View File
@@ -21,6 +21,19 @@ export default {
password: "Password", password: "Password",
login: "Login", login: "Login",
}, },
dashboard: {
stale: "Dashboard data is stale. Retrying automatically...",
topPeers24h: "Top peers (24h)",
refreshFailed: "Failed to refresh dashboard data",
},
admin: {
changePasswordTitle: "Change password",
oldPassword: "Old password",
newPassword: "New password",
forcePasswordChangeNotice:
"You must change your password before continuing.",
changedSuccess: "Password updated",
},
// Локализация навигационной панели // Локализация навигационной панели
navbar: { navbar: {
logout: "Logout", logout: "Logout",
@@ -57,7 +70,7 @@ export default {
yes: "Yes", yes: "Yes",
no: "No", no: "No",
securityRisk: "Security Risks", securityRisk: "Security Risks",
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/peers/list?focus=change-pass" style="color: #00BFFF">Click here</a> to change`, defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`, noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
required: "Required", required: "Required",
warning: "Warning", warning: "Warning",
+13 -1
View File
@@ -19,6 +19,18 @@ export default {
password: "Пароль", password: "Пароль",
login: "Войти", login: "Войти",
}, },
dashboard: {
stale: "Данные дашборда устарели. Выполняется автоматическая повторная попытка...",
topPeers24h: "Топ пиров (24ч)",
refreshFailed: "Не удалось обновить данные дашборда",
},
admin: {
changePasswordTitle: "Смена пароля",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
forcePasswordChangeNotice: "Перед продолжением необходимо сменить пароль.",
changedSuccess: "Пароль обновлён",
},
navbar: { navbar: {
logout: "Выйти", logout: "Выйти",
}, },
@@ -54,7 +66,7 @@ export default {
yes: "Да", yes: "Да",
no: "Нет", no: "Нет",
securityRisk: "Риски безопасности", securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/peers/list?focus=change-pass" style="color: #00BFFF">Перейти к смене</a>`, defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`, noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
required: "Обязательное поле", required: "Обязательное поле",
warning: "Внимание", warning: "Внимание",
+9 -2
View File
@@ -24,6 +24,10 @@ router.beforeEach(async (to, from, next) => {
const adminStore = useAdminStoreHook(); const adminStore = useAdminStoreHook();
const hasRoles = adminStore.roles && adminStore.roles.length > 0; const hasRoles = adminStore.roles && adminStore.roles.length > 0;
if (hasRoles) { if (hasRoles) {
if (adminStore.forcePasswordChange && to.path !== "/admin/change-password") {
next({ path: "/admin/change-password", query: { redirect: to.fullPath } });
return;
}
// Если маршрут не найден, перейти на 404 // Если маршрут не найден, перейти на 404
if (to.matched.length === 0) { if (to.matched.length === 0) {
from.name ? next({ name: from.name }) : next("/404"); from.name ? next({ name: from.name }) : next("/404");
@@ -32,11 +36,15 @@ router.beforeEach(async (to, from, next) => {
} }
} else { } else {
try { try {
const { roles } = await adminStore.getAdminInfo(); const { roles, forcePasswordChange } = await adminStore.getAdminInfo();
const accessRoutes = permissionStore.generateRoutes(roles); const accessRoutes = permissionStore.generateRoutes(roles);
accessRoutes.forEach((route) => { accessRoutes.forEach((route) => {
router.addRoute(route); router.addRoute(route);
}); });
if (forcePasswordChange && to.path !== "/admin/change-password") {
next({ path: "/admin/change-password", query: { redirect: to.fullPath }, replace: true });
return;
}
next({ ...to, replace: true }); next({ ...to, replace: true });
} catch (error) { } catch (error) {
// Удалить token и перейти на страницу входа // Удалить token и перейти на страницу входа
@@ -61,4 +69,3 @@ router.afterEach(() => {
NProgress.done(); NProgress.done();
}); });
+20 -6
View File
@@ -53,7 +53,7 @@ export const asyncRoutes: any[] = [
name: "Dashboard", name: "Dashboard",
meta: { meta: {
title: "dashboard", title: "dashboard",
icon: "dashboard", icon: "report",
roles: ["admin"], roles: ["admin"],
}, },
children: [ children: [
@@ -63,16 +63,30 @@ export const asyncRoutes: any[] = [
name: "DashboardIndex", name: "DashboardIndex",
meta: { meta: {
title: "dashboard", title: "dashboard",
icon: "dashboard", icon: "report",
roles: ["admin"], roles: ["admin"],
}, },
}, },
], ],
}, },
{
path: "/admin",
component: "Layout",
redirect: "/admin/change-password",
meta: { hidden: true, roles: ["admin"] },
children: [
{
path: "change-password",
component: "admin/change-password/index",
name: "AdminChangePassword",
meta: { title: "changePassword", hidden: true, roles: ["admin"] },
},
],
},
{ {
path: "/peers", path: "/peers",
component: "Layout", component: "Layout",
redirect: "/list", redirect: "/peers/list",
name: "Peer", name: "Peer",
meta: { title: "peer", icon: "users", roles: ["admin"] }, meta: { title: "peer", icon: "users", roles: ["admin"] },
children: [ children: [
@@ -94,7 +108,7 @@ export const asyncRoutes: any[] = [
{ {
path: "/hysteria", path: "/hysteria",
component: "Layout", component: "Layout",
redirect: "/list", redirect: "/hysteria/list",
name: "Hysteria", name: "Hysteria",
meta: { title: "hysteria", icon: "hysteria", roles: ["admin"] }, meta: { title: "hysteria", icon: "hysteria", roles: ["admin"] },
children: [ children: [
@@ -113,7 +127,7 @@ export const asyncRoutes: any[] = [
{ {
path: "/config", path: "/config",
component: "Layout", component: "Layout",
redirect: "/list", redirect: "/config/list",
name: "Config", name: "Config",
meta: { title: "config", icon: "setting", roles: ["admin"] }, meta: { title: "config", icon: "setting", roles: ["admin"] },
children: [ children: [
@@ -135,7 +149,7 @@ export const asyncRoutes: any[] = [
{ {
path: "/log", path: "/log",
component: "Layout", component: "Layout",
redirect: "/system", redirect: "/log/system",
name: "Log", name: "Log",
meta: { title: "log", icon: "error", roles: ["admin"] }, meta: { title: "log", icon: "error", roles: ["admin"] },
children: [ children: [
+6 -1
View File
@@ -10,6 +10,7 @@ import { useStorage } from "@vueuse/core";
export const useAdminStore = defineStore("admin", () => { export const useAdminStore = defineStore("admin", () => {
const token = useStorage("accessToken", ""); const token = useStorage("accessToken", "");
const forcePasswordChange = useStorage("forcePasswordChange", false);
const id = ref(0); const id = ref(0);
const username = ref(""); const username = ref("");
const roles = ref<Array<string>>([]); const roles = ref<Array<string>>([]);
@@ -18,8 +19,9 @@ export const useAdminStore = defineStore("admin", () => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
loginApi(adminLoginDto) loginApi(adminLoginDto)
.then((response) => { .then((response) => {
const { tokenType, accessToken } = response.data; const { tokenType, accessToken, forcePasswordChange: mustChange } = response.data;
token.value = tokenType + " " + accessToken; token.value = tokenType + " " + accessToken;
forcePasswordChange.value = !!mustChange;
resolve(); resolve();
}) })
.catch((error) => { .catch((error) => {
@@ -41,6 +43,7 @@ export const useAdminStore = defineStore("admin", () => {
id.value = data.id; id.value = data.id;
username.value = data.username; username.value = data.username;
roles.value = data.roles; roles.value = data.roles;
forcePasswordChange.value = !!data.forcePasswordChange;
resolve(data); resolve(data);
}) })
.catch((error) => { .catch((error) => {
@@ -59,6 +62,7 @@ export const useAdminStore = defineStore("admin", () => {
function resetToken() { function resetToken() {
token.value = ""; token.value = "";
forcePasswordChange.value = false;
id.value = 0; id.value = 0;
username.value = ""; username.value = "";
roles.value = []; roles.value = [];
@@ -66,6 +70,7 @@ export const useAdminStore = defineStore("admin", () => {
return { return {
token, token,
forcePasswordChange,
id, id,
username, username,
roles, roles,
@@ -0,0 +1,85 @@
<template>
<div class="app-container">
<el-card shadow="never" class="change-password-card">
<template #header>{{ $t("admin.changePasswordTitle") }}</template>
<el-alert
:title="$t('admin.forcePasswordChangeNotice')"
type="warning"
:closable="false"
class="mb-3"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="180px">
<el-form-item :label="$t('admin.oldPassword')" prop="oldPassword">
<el-input v-model="form.oldPassword" show-password />
</el-form-item>
<el-form-item :label="$t('admin.newPassword')" prop="newPassword">
<el-input v-model="form.newPassword" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ $t("common.confirm") }}</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/admin";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const adminStore = useAdminStore();
const formRef = ref<FormInstance>();
const saving = ref(false);
const form = reactive({
oldPassword: "",
newPassword: "",
});
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
const rules: FormRules = {
oldPassword: [
{ required: true, message: t("common.required"), trigger: ["change", "blur"] },
{ pattern: passwordPattern, message: t("common.invalid"), trigger: ["change", "blur"] },
],
newPassword: [
{ required: true, message: t("common.required"), trigger: ["change", "blur"] },
{ pattern: passwordPattern, message: t("common.invalid"), trigger: ["change", "blur"] },
],
};
const submit = async () => {
if (!formRef.value) {
return;
}
const valid = await formRef.value.validate();
if (!valid) {
return;
}
saving.value = true;
try {
await adminChangePasswordApi({ oldPassword: form.oldPassword, newPassword: form.newPassword });
adminStore.forcePasswordChange = false;
ElMessage.success(t("admin.changedSuccess"));
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
router.push(redirect);
} finally {
saving.value = false;
}
};
</script>
<style scoped lang="scss">
.change-password-card {
max-width: 760px;
}
</style>
+8 -5
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="dashboard-container"> <div class="dashboard-container">
<div class="dashboard-actions mb-2"> <div class="dashboard-actions mb-2">
<el-button size="small" @click="loadDashboard">Refresh</el-button> <el-button size="small" @click="loadDashboard">{{ $t("common.refresh") }}</el-button>
</div> </div>
<el-alert <el-alert
@@ -13,7 +13,7 @@
/> />
<el-alert <el-alert
v-else-if="isStale" v-else-if="isStale"
title="Dashboard data is stale. Retrying automatically..." :title="$t('dashboard.stale')"
type="warning" type="warning"
:closable="false" :closable="false"
class="mb-2" class="mb-2"
@@ -22,7 +22,7 @@
<el-alert <el-alert
v-for="risk in securityRisks" v-for="risk in securityRisks"
:key="risk.key" :key="risk.key"
:title="risk.key" :title="$t(risk.key)"
:type="risk.severity === 'critical' ? 'error' : risk.severity === 'warning' ? 'warning' : 'info'" :type="risk.severity === 'critical' ? 'error' : risk.severity === 'warning' ? 'warning' : 'info'"
:closable="risk.dismissible" :closable="risk.dismissible"
class="mb-2" class="mb-2"
@@ -40,7 +40,7 @@
</el-row> </el-row>
<el-card shadow="never" class="mt-3"> <el-card shadow="never" class="mt-3">
<template #header>Top peers (24h)</template> <template #header>{{ $t("dashboard.topPeers24h") }}</template>
<el-table :data="topPeers" size="small"> <el-table :data="topPeers" size="small">
<el-table-column prop="name" label="Peer" /> <el-table-column prop="name" label="Peer" />
<el-table-column prop="download" label="Download"> <el-table-column prop="download" label="Download">
@@ -59,10 +59,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useIntervalFn } from "@vueuse/core"; import { useIntervalFn } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { dashboardSecurityApi, dashboardSummaryApi, dashboardTopPeersApi } from "@/api/dashboard"; import { dashboardSecurityApi, dashboardSummaryApi, dashboardTopPeersApi } from "@/api/dashboard";
import { DashboardSummaryVo, DashboardTopPeerVo, SecurityRiskVo } from "@/api/dashboard/types"; import { DashboardSummaryVo, DashboardTopPeerVo, SecurityRiskVo } from "@/api/dashboard/types";
import { formatBytes } from "@/utils/byte"; import { formatBytes } from "@/utils/byte";
const { t } = useI18n();
const summary = ref<DashboardSummaryVo>({ const summary = ref<DashboardSummaryVo>({
collectedAt: 0, collectedAt: 0,
system: { cpuPercent: 0, memUsedBytes: 0, memTotalBytes: 0, memPercent: 0, diskUsedBytes: 0, diskTotalBytes: 0, diskPercent: 0 }, system: { cpuPercent: 0, memUsedBytes: 0, memTotalBytes: 0, memPercent: 0, diskUsedBytes: 0, diskTotalBytes: 0, diskPercent: 0 },
@@ -108,7 +111,7 @@ const loadDashboard = async () => {
lastSuccessAt.value = Date.now(); lastSuccessAt.value = Date.now();
loadError.value = ""; loadError.value = "";
} catch (error) { } catch (error) {
loadError.value = "Failed to refresh dashboard data"; loadError.value = t("dashboard.refreshFailed");
} finally { } finally {
loading.value = false; loading.value = false;
} }
+4
View File
@@ -160,6 +160,10 @@ const handleLogin = () => {
adminStore adminStore
.login(params) .login(params)
.then(() => { .then(() => {
if (adminStore.forcePasswordChange) {
router.push({ path: "/admin/change-password" });
return;
}
const query: LocationQuery = route.query; const query: LocationQuery = route.query;
const redirect = (query.redirect as LocationQueryValue) ?? "/"; const redirect = (query.redirect as LocationQueryValue) ?? "/";
+16 -1
View File
@@ -28,11 +28,26 @@ func JWTHandler() gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
if myClaims.Admin.Deleted != 0 { admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id)
if err != nil {
vo.Fail(err.Error(), c)
c.Abort()
return
}
if admin.Status != nil && *admin.Status != 1 {
vo.Fail("this account has been disabled", c) vo.Fail("this account has been disabled", c)
c.Abort() c.Abort()
return return
} }
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion
}
if myClaims.Admin.TokenVersion != tokenVersion {
vo.Fail(constant.IllegalTokenError, c)
c.Abort()
return
}
c.Next() c.Next()
} }
} }
+1
View File
@@ -32,6 +32,7 @@ type AccountExport struct {
type PeerExport struct { type PeerExport struct {
Id int64 `json:"id,omitempty"` Id int64 `json:"id,omitempty"`
AuthId string `json:"authId,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Secret string `json:"secret,omitempty"` Secret string `json:"secret,omitempty"`
+5
View File
@@ -8,6 +8,7 @@ import (
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/util" "hy2xs-admin/util"
) )
@@ -96,3 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
}) })
} }
func GetAdminForTokenValidation(id int64) (entity.AdminUser, error) {
return dao.GetAdminUser("id = ?", id)
}
+100 -23
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"hy2xs-admin/dao" "hy2xs-admin/dao"
@@ -36,7 +37,8 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
BannedUntil: int64Val(p.BannedUntil), BannedUntil: int64Val(p.BannedUntil),
LastConnectionAt: int64Val(p.LastConnectionAt), LastConnectionAt: int64Val(p.LastConnectionAt),
} }
if v, ok := onlineUsers[item.AuthId]; ok { authID := strVal(p.AuthId)
if v, ok := onlineUsers[authID]; ok {
item.Online = true item.Online = true
item.OnlineDevices = v item.OnlineDevices = v
} }
@@ -181,6 +183,7 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
for _, item := range peers { for _, item := range peers {
ex := bo.PeerExport{ ex := bo.PeerExport{
Id: int64Val(item.Id), Id: int64Val(item.Id),
AuthId: strVal(item.AuthId),
Name: strVal(item.Name), Name: strVal(item.Name),
Remark: strVal(item.Remark), Remark: strVal(item.Remark),
QuotaBytes: int64Val(item.QuotaBytes), QuotaBytes: int64Val(item.QuotaBytes),
@@ -202,32 +205,106 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
return out, nil return out, nil
} }
func UpsertPeerLegacy(accounts []entity.Account) error { func UpsertPeerExport(items []bo.PeerExport) error {
for _, account := range accounts { for _, item := range items {
if account.Id != nil && *account.Id > 0 { name := strings.TrimSpace(item.Name)
upd := dto.PeerUpdateDto{} if name == "" {
upd.Name = account.Username return errors.New(constant.InvalidError)
upd.Remark = account.Remark }
upd.QuotaBytes = account.Quota
upd.ExpiresAt = account.ExpireTime var existing entity.Peer
upd.MaxDevices = account.DeviceNo var err error
upd.Disabled = account.Deleted authID := strings.TrimSpace(item.AuthId)
if err := UpdatePeer(*account.Id, upd); err != nil { if authID != "" {
return err existing, err = dao.GetPeer("auth_id = ?", authID)
}
if (err != nil || existing.Id == nil) && name != "" {
existing, err = dao.GetPeer("name = ?", name)
}
quota := item.QuotaBytes
expires := item.ExpiresAt
maxDevices := item.MaxDevices
if maxDevices <= 0 {
maxDevices = 3
}
disabled := item.Disabled
remark := item.Remark
if err == nil && existing.Id != nil {
updates := map[string]interface{}{
"name": name,
"remark": remark,
"quota_bytes": quota,
"download_bytes": item.DownloadBytes,
"upload_bytes": item.UploadBytes,
"expires_at": expires,
"max_devices": maxDevices,
"disabled": disabled,
"banned_until": item.BannedUntil,
"last_connection_at": item.LastConnectionAt,
}
if authID != "" {
updates["auth_id"] = authID
}
if strings.TrimSpace(item.Secret) != "" {
digest, derr := PeerSecretDigest(item.Secret)
if derr != nil {
return derr
}
enc, eerr := EncryptPeerSecret(item.Secret)
if eerr != nil {
return eerr
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
}
if uerr := dao.UpdatePeer([]int64{*existing.Id}, updates); uerr != nil {
return uerr
} }
continue continue
} }
save := dto.PeerSaveDto{
Name: account.Username, createSecret := strings.TrimSpace(item.Secret)
Secret: account.ConPass, if createSecret == "" {
QuotaBytes: account.Quota, generated, gerr := util.RandomString(24)
ExpiresAt: account.ExpireTime, if gerr != nil {
MaxDevices: account.DeviceNo, return gerr
Disabled: account.Deleted, }
Remark: account.Remark, createSecret = fmt.Sprintf("%s.%s", name, generated)
} }
if _, err := CreatePeer(save); err != nil { secretDigest, derr := PeerSecretDigest(createSecret)
return err if derr != nil {
return derr
}
secretEncrypted, eerr := EncryptPeerSecret(createSecret)
if eerr != nil {
return eerr
}
if authID == "" {
authID, err = util.RandomString(18)
if err != nil {
return err
}
}
peer := entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &secretDigest,
SecretEncrypted: &secretEncrypted,
QuotaBytes: &quota,
DownloadBytes: &item.DownloadBytes,
UploadBytes: &item.UploadBytes,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &item.BannedUntil,
LastConnectionAt: &item.LastConnectionAt,
}
if _, serr := dao.SavePeer(peer); serr != nil {
return serr
} }
} }
return nil return nil
-8
View File
@@ -9,7 +9,6 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"os"
"io" "io"
"strings" "strings"
@@ -124,10 +123,3 @@ func DecryptAESGCM(cipherText string, key []byte) (string, error) {
return string(plain), nil return string(plain), nil
} }
func PeerSecretDigest(rawSecret string) string {
secretKey := strings.TrimSpace(os.Getenv("HY2XS_PEER_SECRET_KEY"))
if secretKey == "" {
secretKey = "hy2xs-peer-secret-key"
}
return HmacSHA256Hex(rawSecret, secretKey)
}