diff --git a/backend/app/repo/common.go b/backend/app/repo/common.go index e6cf0b23f..f01d21c91 100644 --- a/backend/app/repo/common.go +++ b/backend/app/repo/common.go @@ -15,6 +15,7 @@ type DBOption func(*gorm.DB) *gorm.DB type ICommonRepo interface { WithByID(id uint) DBOption WithByName(name string) DBOption + WithByLowerName(name string) DBOption WithByType(tp string) DBOption WithOrderBy(orderStr string) DBOption WithOrderRuleBy(orderBy, order string) DBOption @@ -45,6 +46,12 @@ func (c *CommonRepo) WithByName(name string) DBOption { } } +func (c *CommonRepo) WithByLowerName(name string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("LOWER(name) = LOWER(?)", name) + } +} + func (c *CommonRepo) WithByDate(startTime, endTime time.Time) DBOption { return func(g *gorm.DB) *gorm.DB { return g.Where("start_time > ? AND start_time < ?", startTime, endTime) diff --git a/backend/app/repo/setting.go b/backend/app/repo/setting.go index c5453189f..7a494d4a5 100644 --- a/backend/app/repo/setting.go +++ b/backend/app/repo/setting.go @@ -16,6 +16,7 @@ type ISettingRepo interface { Create(key, value string) error Update(key, value string) error WithByKey(key string) DBOption + UpdateOrCreate(key, value string) error CreateMonitorBase(model model.MonitorBase) error BatchCreateMonitorIO(ioList []model.MonitorIO) error @@ -85,3 +86,7 @@ func (u *SettingRepo) DelMonitorIO(timeForDelete time.Time) error { func (u *SettingRepo) DelMonitorNet(timeForDelete time.Time) error { return global.MonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorNetwork{}).Error } + +func (u *SettingRepo) UpdateOrCreate(key, value string) error { + return global.DB.Model(&model.Setting{}).Where("key = ?", key).Assign(model.Setting{Key: key, Value: value}).FirstOrCreate(&model.Setting{}).Error +} diff --git a/backend/app/service/app.go b/backend/app/service/app.go index 159dd6912..644af4c6a 100644 --- a/backend/app/service/app.go +++ b/backend/app/service/app.go @@ -326,7 +326,8 @@ func (a AppService) Install(ctx context.Context, req request.AppInstallCreate) ( err = buserr.WithDetail(constant.Err1PanelNetworkFailed, err.Error(), nil) return } - if list, _ := appInstallRepo.ListBy(commonRepo.WithByName(req.Name)); len(list) > 0 { + + if list, _ := appInstallRepo.ListBy(commonRepo.WithByLowerName(req.Name)); len(list) > 0 { err = buserr.New(constant.ErrAppNameExist) return } diff --git a/backend/app/service/auth.go b/backend/app/service/auth.go index 2e3a2c9e0..cf5c02355 100644 --- a/backend/app/service/auth.go +++ b/backend/app/service/auth.go @@ -3,8 +3,6 @@ package service import ( "crypto/hmac" "encoding/base64" - "strconv" - "github.com/1Panel-dev/1Panel/backend/app/dto" "github.com/1Panel-dev/1Panel/backend/buserr" "github.com/1Panel-dev/1Panel/backend/constant" @@ -15,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/pkg/errors" + "strconv" ) type AuthService struct{} @@ -38,16 +37,11 @@ func (u *AuthService) Login(c *gin.Context, info dto.Login, entrance string) (*d if err != nil { return nil, errors.WithMessage(constant.ErrRecordNotFound, err.Error()) } - passwordSetting, err := settingRepo.Get(settingRepo.WithByKey("Password")) - if err != nil { - return nil, errors.WithMessage(constant.ErrRecordNotFound, err.Error()) - } - pass, err := encrypt.StringDecrypt(passwordSetting.Value) - if err != nil { + if nameSetting.Value != info.Name { return nil, constant.ErrAuth } - if !hmac.Equal([]byte(info.Password), []byte(pass)) || nameSetting.Value != info.Name { - return nil, constant.ErrAuth + if err = checkPassword(info.Password); err != nil { + return nil, err } entranceSetting, err := settingRepo.Get(settingRepo.WithByKey("SecurityEntrance")) if err != nil { @@ -83,17 +77,12 @@ func (u *AuthService) MFALogin(c *gin.Context, info dto.MFALogin, entrance strin if err != nil { return nil, errors.WithMessage(constant.ErrRecordNotFound, err.Error()) } - passwordSetting, err := settingRepo.Get(settingRepo.WithByKey("Password")) - if err != nil { - return nil, errors.WithMessage(constant.ErrRecordNotFound, err.Error()) - } - pass, err := encrypt.StringDecrypt(passwordSetting.Value) - if err != nil { - return nil, err - } - if !hmac.Equal([]byte(info.Password), []byte(pass)) || nameSetting.Value != info.Name { + if nameSetting.Value != info.Name { return nil, constant.ErrAuth } + if err = checkPassword(info.Password); err != nil { + return nil, err + } entranceSetting, err := settingRepo.Get(settingRepo.WithByKey("SecurityEntrance")) if err != nil { return nil, err @@ -219,3 +208,28 @@ func (u *AuthService) IsLogin(c *gin.Context) bool { } return true } + +func checkPassword(password string) error { + priKey, _ := settingRepo.Get(settingRepo.WithByKey("PASSWORD_PRIVATE_KEY")) + + privateKey, err := encrypt.ParseRSAPrivateKey(priKey.Value) + if err != nil { + return err + } + loginPassword, err := encrypt.DecryptPassword(password, privateKey) + if err != nil { + return err + } + passwordSetting, err := settingRepo.Get(settingRepo.WithByKey("Password")) + if err != nil { + return errors.WithMessage(constant.ErrRecordNotFound, err.Error()) + } + existPassword, err := encrypt.StringDecrypt(passwordSetting.Value) + if err != nil { + return err + } + if !hmac.Equal([]byte(loginPassword), []byte(existPassword)) { + return constant.ErrAuth + } + return nil +} diff --git a/backend/app/service/setting.go b/backend/app/service/setting.go index 0049d0335..d0a1f51f6 100644 --- a/backend/app/service/setting.go +++ b/backend/app/service/setting.go @@ -1,6 +1,8 @@ package service import ( + "crypto/rand" + "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/json" @@ -42,6 +44,7 @@ type ISettingService interface { HandlePasswordExpired(c *gin.Context, old, new string) error GenerateApiKey() (string, error) UpdateApiConfig(req dto.ApiInterfaceConfig) error + GenerateRSAKey() error } func NewISettingService() ISettingService { @@ -516,3 +519,47 @@ func (u *SettingService) UpdateApiConfig(req dto.ApiInterfaceConfig) error { global.CONF.System.ApiKeyValidityTime = req.ApiKeyValidityTime return nil } + +func exportPrivateKeyToPEM(privateKey *rsa.PrivateKey) string { + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + }) + return string(privateKeyPEM) +} + +func exportPublicKeyToPEM(publicKey *rsa.PublicKey) (string, error) { + publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return "", err + } + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + }) + return string(publicKeyPEM), nil +} + +func (u *SettingService) GenerateRSAKey() error { + priKey, _ := settingRepo.Get(settingRepo.WithByKey("PASSWORD_PRIVATE_KEY")) + pubKey, _ := settingRepo.Get(settingRepo.WithByKey("PASSWORD_PUBLIC_KEY")) + if priKey.Value != "" && pubKey.Value != "" { + return nil + } + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + privateKeyPEM := exportPrivateKeyToPEM(privateKey) + publicKeyPEM, err := exportPublicKeyToPEM(&privateKey.PublicKey) + err = settingRepo.UpdateOrCreate("PASSWORD_PRIVATE_KEY", privateKeyPEM) + if err != nil { + return err + } + err = settingRepo.UpdateOrCreate("PASSWORD_PUBLIC_KEY", publicKeyPEM) + if err != nil { + return err + } + return nil +} diff --git a/backend/init/business/business.go b/backend/init/business/business.go index 4a544b5da..22f23291a 100644 --- a/backend/init/business/business.go +++ b/backend/init/business/business.go @@ -11,6 +11,7 @@ func Init() { go syncInstalledApp() go syncRuntime() go syncSSL() + generateKey() } func syncApp() { @@ -38,3 +39,9 @@ func syncSSL() { global.LOG.Errorf("sync ssl status error : %s", err.Error()) } } + +func generateKey() { + if err := service.NewISettingService().GenerateRSAKey(); err != nil { + global.LOG.Errorf("generate rsa key error : %s", err.Error()) + } +} diff --git a/backend/init/router/router.go b/backend/init/router/router.go index 4ef0aee4f..1745bee19 100644 --- a/backend/init/router/router.go +++ b/backend/init/router/router.go @@ -113,9 +113,7 @@ func setWebStatic(rootRouter *gin.RouterGroup) { rootRouter.StaticFS("/public", http.FS(web.Favicon)) rootRouter.StaticFS("/favicon.ico", http.FS(web.Favicon)) rootRouter.Static("/api/v1/images", "./uploads") - rootRouter.Use(func(c *gin.Context) { - c.Next() - }) + rootRouter.GET("/assets/*filepath", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d", 3600)) staticServer := http.FileServer(http.FS(web.Assets)) @@ -158,6 +156,7 @@ func Routers() *gin.Engine { Router.Use(middleware.WhiteAllow()) Router.Use(middleware.BindDomain()) + Router.Use(middleware.SetPasswordPublicKey()) Router.NoRoute(func(c *gin.Context) { if checkFrontendPath(c) { diff --git a/backend/middleware/password_rsa.go b/backend/middleware/password_rsa.go new file mode 100644 index 000000000..63f757a0e --- /dev/null +++ b/backend/middleware/password_rsa.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "encoding/base64" + "github.com/1Panel-dev/1Panel/backend/app/repo" + "github.com/gin-gonic/gin" +) + +func SetPasswordPublicKey() gin.HandlerFunc { + return func(c *gin.Context) { + cookieKey, _ := c.Cookie("panel_public_key") + settingRepo := repo.NewISettingRepo() + key, _ := settingRepo.Get(settingRepo.WithByKey("PASSWORD_PUBLIC_KEY")) + base64Key := base64.StdEncoding.EncodeToString([]byte(key.Value)) + if base64Key == cookieKey { + c.Next() + return + } + c.SetCookie("panel_public_key", base64Key, 7*24*60*60, "/", "", false, false) + c.Next() + } +} diff --git a/backend/utils/encrypt/encrypt.go b/backend/utils/encrypt/encrypt.go index 62d4e1cde..28bc148cf 100644 --- a/backend/utils/encrypt/encrypt.go +++ b/backend/utils/encrypt/encrypt.go @@ -5,9 +5,14 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" + "encoding/pem" + "errors" "fmt" "io" + "strings" "github.com/1Panel-dev/1Panel/backend/app/model" "github.com/1Panel-dev/1Panel/backend/global" @@ -102,3 +107,75 @@ func aesDecryptWithSalt(key, ciphertext []byte) ([]byte, error) { ciphertext = unPadding(ciphertext) return ciphertext, nil } + +func ParseRSAPrivateKey(privateKeyPEM string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, errors.New("failed to decode PEM block containing the private key") + } + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return privateKey, nil +} + +func aesDecrypt(ciphertext, key, iv []byte) ([]byte, error) { + if len(key) != 16 && len(key) != 24 && len(key) != 32 { + return nil, errors.New("invalid AES key length: must be 16, 24, or 32 bytes") + } + if len(iv) != aes.BlockSize { + return nil, errors.New("invalid IV length: must be 16 bytes") + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(ciphertext, ciphertext) + ciphertext = pkcs7Unpad(ciphertext) + return ciphertext, nil +} + +func pkcs7Unpad(data []byte) []byte { + length := len(data) + padLength := int(data[length-1]) + return data[:length-padLength] +} + +func DecryptPassword(encryptedData string, privateKey *rsa.PrivateKey) (string, error) { + parts := strings.Split(encryptedData, ":") + if len(parts) != 3 { + return "", errors.New("encrypted data format error") + } + keyCipher := parts[0] + ivBase64 := parts[1] + ciphertextBase64 := parts[2] + + encryptedAESKey, err := base64.StdEncoding.DecodeString(keyCipher) + if err != nil { + return "", errors.New("failed to decode keyCipher") + } + + aesKey, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, encryptedAESKey) + if err != nil { + return "", errors.New("failed to decode AES Key") + } + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil { + return "", errors.New("failed to decrypt the encrypted data") + } + iv, err := base64.StdEncoding.DecodeString(ivBase64) + if err != nil { + return "", errors.New("failed to decode the IV") + } + + password, err := aesDecrypt(ciphertext, aesKey, iv) + if err != nil { + return "", err + } + + return string(password), nil +} diff --git a/frontend/package.json b/frontend/package.json index c51eef932..55ae361dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.2", "@codemirror/legacy-modes": "^6.4.0", "@codemirror/state": "^6.4.1", @@ -37,11 +37,13 @@ "@xterm/xterm": "^5.5.0", "axios": "^1.7.2", "codemirror": "^6.0.1", + "crypto-js": "^4.2.0", "echarts": "^5.5.0", "element-plus": "^2.7.5", "fit2cloud-ui-plus": "^1.2.0", "highlight.js": "^11.9.0", "js-base64": "^3.7.7", + "jsencrypt": "^3.3.2", "md-editor-v3": "^2.11.3", "monaco-editor": "^0.50.0", "nprogress": "^0.2.0", @@ -54,11 +56,12 @@ "vue-clipboard3": "^2.0.0", "vue-codemirror": "^6.1.1", "vue-demi": "^0.14.6", - "vue-i18n": "^10.0.5", - "vue-router": "^4.3.3" + "vue-i18n": "^9.13.1", + "vue-router": "^4.3.3", + "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { - "@types/node": "^20.14.2", + "@types/node": "^20.15.0", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "@vitejs/plugin-vue": "^5.0.5", @@ -73,7 +76,7 @@ "postcss": "^8.4.31", "postcss-html": "^1.4.1", "prettier": "^2.6.2", - "rollup-plugin-visualizer": "^5.5.4", + "rollup-plugin-visualizer": "^5.14.0", "sass": "^1.77.8", "standard-version": "^9.5.0", "stylelint": "^15.10.1", @@ -81,7 +84,7 @@ "typescript": "^4.5.4", "unplugin-auto-import": "^0.16.4", "unplugin-vue-components": "^0.25.0", - "vite": "^5.2.13", + "vite": "^6.0.7", "vite-plugin-compression": "^0.5.1", "vite-plugin-eslint": "^1.8.1", "vite-plugin-html": "^3.2.2", @@ -90,7 +93,7 @@ "vue-tsc": "^0.29.8" }, "overrides": { - "esbuild": "npm:esbuild-wasm@latest" + "esbuild": "npm:esbuild-wasm@0.24.2" }, "config": { "commitizen": { diff --git a/frontend/src/global/form-rules.ts b/frontend/src/global/form-rules.ts index 5b86a4a6b..d3ba996d2 100644 --- a/frontend/src/global/form-rules.ts +++ b/frontend/src/global/form-rules.ts @@ -289,7 +289,7 @@ const checkAppName = (rule: any, value: any, callback: any) => { if (value === '' || typeof value === 'undefined' || value == null) { callback(new Error(i18n.global.t('commons.rule.appName'))); } else { - const reg = /^(?![_-])[a-z0-9_-]{1,29}[a-zA-Z0-9]$/; + const reg = /^(?![_-])[a-zA-Z0-9_-]{1,29}[a-zA-Z0-9]$/; if (!reg.test(value) && value !== '') { callback(new Error(i18n.global.t('commons.rule.appName'))); } else { diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index c5472710a..e56fbfdca 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1451,8 +1451,7 @@ const message = { confDockerProxy: 'Configurar proxy do Docker', restartNowHelper: 'Configurar o proxy do Docker exige reiniciar o serviço Docker.', restartNow: 'Reiniciar imediatamente', - systemIPWarning: - 'O endereço do sistema não está definido no momento. Defina-o primeiro no painel de controle.', + systemIPWarning: 'O endereço do sistema não está definido no momento. Defina-o primeiro no painel de controle.', systemIPWarning1: 'O endereço do sistema atual está definido como {0}, e o redirecionamento rápido não é possível!', defaultNetwork: 'Placa de rede', diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 7a96efc84..475adc279 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -3,6 +3,8 @@ import i18n from '@/lang'; import useClipboard from 'vue-clipboard3'; const { toClipboard } = useClipboard(); import { MsgError, MsgSuccess } from '@/utils/message'; +import JSEncrypt from 'jsencrypt'; +import CryptoJS from 'crypto-js'; export function deepCopy(obj: any): T { let newObj: any; @@ -615,3 +617,61 @@ export const escapeProxyURL = (url: string): string => { return url.replace(/[\/:?#[\]@!$&'()*+,;=%~]/g, (match) => encodeMap[match] || match); }; + +function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + +function rsaEncrypt(data: string, publicKey: string) { + if (!data) { + return data; + } + const jsEncrypt = new JSEncrypt(); + jsEncrypt.setPublicKey(publicKey); + return jsEncrypt.encrypt(data); +} + +function aesEncrypt(data: string, key: string) { + const keyBytes = CryptoJS.enc.Utf8.parse(key); + const iv = CryptoJS.lib.WordArray.random(16); + const encrypted = CryptoJS.AES.encrypt(data, keyBytes, { + iv: iv, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }); + return iv.toString(CryptoJS.enc.Base64) + ':' + encrypted.toString(); +} + +function urlDecode(value: string): string { + return decodeURIComponent(value.replace(/\+/g, ' ')); +} + +function generateAESKey(): string { + const keyLength = 16; + const randomBytes = new Uint8Array(keyLength); + crypto.getRandomValues(randomBytes); + return Array.from(randomBytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export const encryptPassword = (password: string) => { + if (!password) { + return ''; + } + let rsaPublicKeyText = getCookie('panel_public_key'); + if (!rsaPublicKeyText) { + console.log('RSA public key not found'); + return password; + } + rsaPublicKeyText = urlDecode(rsaPublicKeyText); + + const aesKey = generateAESKey(); + rsaPublicKeyText = rsaPublicKeyText.replaceAll('"', ''); + const rsaPublicKey = atob(rsaPublicKeyText); + const keyCipher = rsaEncrypt(aesKey, rsaPublicKey); + const passwordCipher = aesEncrypt(password, aesKey); + return `${keyCipher}:${passwordCipher}`; +}; diff --git a/frontend/src/views/login/components/login-form.vue b/frontend/src/views/login/components/login-form.vue index e24f4524c..3fef2b5dc 100644 --- a/frontend/src/views/login/components/login-form.vue +++ b/frontend/src/views/login/components/login-form.vue @@ -180,6 +180,7 @@ import { MsgSuccess } from '@/utils/message'; import { useI18n } from 'vue-i18n'; import { getSettingInfo } from '@/api/modules/setting'; import { Rules } from '@/global/form-rules'; +import { encryptPassword } from '@/utils/util'; const i18n = useI18n(); const themeConfig = computed(() => globalStore.themeConfig); @@ -314,7 +315,7 @@ const login = (formEl: FormInstance | undefined) => { } let requestLoginForm = { name: loginForm.name, - password: loginForm.password, + password: encryptPassword(loginForm.password), ignoreCaptcha: globalStore.ignoreCaptcha, captcha: loginForm.captcha, captchaID: captcha.captchaID, @@ -370,7 +371,7 @@ const mfaLogin = async (auto: boolean) => { if ((!auto && mfaLoginForm.code) || (auto && mfaLoginForm.code.length === 6)) { isLoggingIn = true; mfaLoginForm.name = loginForm.name; - mfaLoginForm.password = loginForm.password; + mfaLoginForm.password = encryptPassword(loginForm.password); const res = await mfaLoginApi(mfaLoginForm); if (res.code === 406) { errMfaInfo.value = true;