fix26: полный прод-фикс auth/jwt, import-export peer и frontend flow
This commit is contained in:
+45
-15
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Внимание",
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
@@ -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: "a,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user