diff --git a/controllers/auth.go b/controllers/auth.go index acf2cf10..900cfd5b 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -463,6 +463,15 @@ func (c *ApiController) Login() { } password := authForm.Password + + if application.OrganizationObj != nil { + password, err = util.GetUnobfuscatedPassword(application.OrganizationObj.PasswordObfuscatorType, application.OrganizationObj.PasswordObfuscatorKey, authForm.Password) + if err != nil { + c.ResponseError(err.Error()) + return + } + } + isSigninViaLdap := authForm.SigninMethod == "LDAP" var isPasswordWithLdapEnabled bool if authForm.SigninMethod == "Password" { diff --git a/object/organization.go b/object/organization.go index 097b5d7a..b54885e7 100644 --- a/object/organization.go +++ b/object/organization.go @@ -60,6 +60,8 @@ type Organization struct { PasswordType string `xorm:"varchar(100)" json:"passwordType"` PasswordSalt string `xorm:"varchar(100)" json:"passwordSalt"` PasswordOptions []string `xorm:"varchar(100)" json:"passwordOptions"` + PasswordObfuscatorType string `xorm:"varchar(100)" json:"passwordObfuscatorType"` + PasswordObfuscatorKey string `xorm:"varchar(100)" json:"passwordObfuscatorKey"` CountryCodes []string `xorm:"varchar(200)" json:"countryCodes"` DefaultAvatar string `xorm:"varchar(200)" json:"defaultAvatar"` DefaultApplication string `xorm:"varchar(100)" json:"defaultApplication"` diff --git a/util/obfuscator.go b/util/obfuscator.go new file mode 100644 index 00000000..deabb031 --- /dev/null +++ b/util/obfuscator.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/des" + "encoding/hex" + "fmt" +) + +func unPaddingPkcs7(s []byte) []byte { + length := len(s) + if length == 0 { + return s + } + unPadding := int(s[length-1]) + return s[:(length - unPadding)] +} + +func decryptDesOrAes(passwordCipher string, block cipher.Block) (string, error) { + passwordCipherBytes, err := hex.DecodeString(passwordCipher) + if err != nil { + return "", err + } + + if len(passwordCipherBytes) < block.BlockSize() { + return "", fmt.Errorf("the password ciphertext should contain a random hexadecimal string of length %d at the beginning", block.BlockSize()*2) + } + + iv := passwordCipherBytes[:block.BlockSize()] + password := make([]byte, len(passwordCipherBytes)-block.BlockSize()) + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(password, passwordCipherBytes[block.BlockSize():]) + + return string(unPaddingPkcs7(password)), nil +} + +func GetUnobfuscatedPassword(passwordObfuscatorType string, passwordObfuscatorKey string, passwordCipher string) (string, error) { + if passwordObfuscatorType == "Plain" || passwordObfuscatorType == "" { + return passwordCipher, nil + } else if passwordObfuscatorType == "DES" || passwordObfuscatorType == "AES" { + key, err := hex.DecodeString(passwordObfuscatorKey) + if err != nil { + return "", err + } + + var block cipher.Block + if passwordObfuscatorType == "DES" { + block, err = des.NewCipher(key) + } else { + block, err = aes.NewCipher(key) + } + if err != nil { + return "", err + } + + return decryptDesOrAes(passwordCipher, block) + } else { + return "", fmt.Errorf("unsupported password obfuscator type: %s", passwordObfuscatorType) + } +} diff --git a/web/package.json b/web/package.json index 17fa87e6..3d9d210d 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "copy-to-clipboard": "^3.3.1", "core-js": "^3.25.0", "craco-less": "^2.0.0", + "crypto-js": "^4.2.0", "echarts": "^5.4.3", "ethers": "5.6.9", "face-api.js": "^0.22.2", diff --git a/web/src/OrganizationEditPage.js b/web/src/OrganizationEditPage.js index 4dcc468c..412cefc4 100644 --- a/web/src/OrganizationEditPage.js +++ b/web/src/OrganizationEditPage.js @@ -19,6 +19,7 @@ import * as ApplicationBackend from "./backend/ApplicationBackend"; import * as LdapBackend from "./backend/LdapBackend"; import * as Setting from "./Setting"; import * as Conf from "./Conf"; +import * as Obfuscator from "./auth/Obfuscator"; import i18next from "i18next"; import {LinkOutlined} from "@ant-design/icons"; import LdapTable from "./table/LdapTable"; @@ -112,6 +113,22 @@ class OrganizationEditPage extends React.Component { }); } + updatePasswordObfuscator(key, value) { + const organization = this.state.organization; + if (organization.passwordObfuscatorType === "") { + organization.passwordObfuscatorType = "Plain"; + } + if (key === "type") { + organization.passwordObfuscatorType = value; + organization.passwordObfuscatorKey = Obfuscator.getRandomKeyForObfuscator(value); + } else if (key === "key") { + organization.passwordObfuscatorKey = value; + } + this.setState({ + organization: organization, + }); + } + renderOrganization() { return ( + + + {Setting.getLabel(i18next.t("general:Password obfuscator"), i18next.t("general:Password obfuscator - Tooltip"))} : + + + + + + { + (this.state.organization.passwordObfuscatorType === "Plain" || this.state.organization.passwordObfuscatorType === "") ? null : ( + + {Setting.getLabel(i18next.t("general:Password obf key"), i18next.t("general:Password obf key - Tooltip"))} : + + + {this.updatePasswordObfuscator("key", e.target.value);}} /> + + ) + } {Setting.getLabel(i18next.t("general:Supported country codes"), i18next.t("general:Supported country codes - Tooltip"))} : @@ -528,6 +573,12 @@ class OrganizationEditPage extends React.Component { const organization = Setting.deepCopy(this.state.organization); organization.accountItems = organization.accountItems?.filter(accountItem => accountItem.name !== "Please select an account item"); + const passwordObfuscatorErrorMessage = Obfuscator.checkPasswordObfuscator(organization.passwordObfuscatorType, organization.passwordObfuscatorKey); + if (passwordObfuscatorErrorMessage.length > 0) { + Setting.showMessage("error", passwordObfuscatorErrorMessage); + return; + } + OrganizationBackend.updateOrganization(this.state.organization.owner, this.state.organizationName, organization) .then((res) => { if (res.status === "ok") { diff --git a/web/src/OrganizationListPage.js b/web/src/OrganizationListPage.js index ec87adea..75e71a61 100644 --- a/web/src/OrganizationListPage.js +++ b/web/src/OrganizationListPage.js @@ -35,6 +35,8 @@ class OrganizationListPage extends BaseListPage { passwordType: "plain", PasswordSalt: "", passwordOptions: [], + passwordObfuscatorType: "Plain", + passwordObfuscatorKey: "", countryCodes: ["US"], defaultAvatar: `${Setting.StaticBaseUrl}/img/casbin.svg`, defaultApplication: "", diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js index bac10773..c1151638 100644 --- a/web/src/auth/LoginPage.js +++ b/web/src/auth/LoginPage.js @@ -19,6 +19,7 @@ import {withRouter} from "react-router-dom"; import * as UserWebauthnBackend from "../backend/UserWebauthnBackend"; import OrganizationSelect from "../common/select/OrganizationSelect"; import * as Conf from "../Conf"; +import * as Obfuscator from "./Obfuscator"; import * as AuthBackend from "./AuthBackend"; import * as OrganizationBackend from "../backend/OrganizationBackend"; import * as ApplicationBackend from "../backend/ApplicationBackend"; @@ -379,6 +380,14 @@ class LoginPage extends React.Component { return; } if (this.state.loginMethod === "password" || this.state.loginMethod === "ldap") { + const organization = this.getApplicationObj()?.organizationObj; + const [passwordCipher, errorMessage] = Obfuscator.encryptByPasswordObfuscator(organization?.passwordObfuscatorType, organization?.passwordObfuscatorKey, values["password"]); + if (errorMessage.length > 0) { + Setting.showMessage("error", errorMessage); + return; + } else { + values["password"] = passwordCipher; + } if (this.state.enableCaptchaModal === CaptchaRule.Always) { this.setState({ openCaptchaModal: true, diff --git a/web/src/auth/Obfuscator.js b/web/src/auth/Obfuscator.js new file mode 100644 index 00000000..fae8c15c --- /dev/null +++ b/web/src/auth/Obfuscator.js @@ -0,0 +1,95 @@ +// Copyright 2024 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import CryptoJS from "crypto-js"; +import i18next from "i18next"; + +export function getRandomKeyForObfuscator(obfuscatorType) { + if (obfuscatorType === "DES") { + return getRandomHexKey(16); + } else if (obfuscatorType === "AES") { + return getRandomHexKey(32); + } else { + return ""; + } +} + +export const passwordObfuscatorKeyRegexes = { + "DES": /^[1-9a-f]{16}$/, + "AES": /^[1-9a-f]{32}$/, +}; + +function encrypt(cipher, key, iv, password) { + const encrypted = cipher.encrypt( + CryptoJS.enc.Hex.parse(Buffer.from(password, "utf-8").toString("hex")), + CryptoJS.enc.Hex.parse(key), + { + iv: iv, + mode: CryptoJS.mode.CBC, + pad: CryptoJS.pad.Pkcs7, + } + ); + return iv.concat(encrypted.ciphertext).toString(CryptoJS.enc.Hex); +} + +export function checkPasswordObfuscator(passwordObfuscatorType, passwordObfuscatorKey) { + if (passwordObfuscatorType === undefined) { + return i18next.t("organization:failed to get password obfuscator"); + } else if (passwordObfuscatorType === "Plain" || passwordObfuscatorType === "") { + return ""; + } else if (passwordObfuscatorType === "AES" || passwordObfuscatorType === "DES") { + if (passwordObfuscatorKeyRegexes[passwordObfuscatorType].test(passwordObfuscatorKey)) { + return ""; + } else { + return `${i18next.t("organization:The password obfuscator key doesn't match the regex")}: ${passwordObfuscatorKeyRegexes[passwordObfuscatorType].source}`; + } + } else { + return `${i18next.t("organization:unsupported password obfuscator type")}: ${passwordObfuscatorType}`; + } +} + +export function encryptByPasswordObfuscator(passwordObfuscatorType, passwordObfuscatorKey, password) { + const passwordObfuscatorErrorMessage = checkPasswordObfuscator(passwordObfuscatorType, passwordObfuscatorKey); + if (passwordObfuscatorErrorMessage.length > 0) { + return ["", passwordObfuscatorErrorMessage]; + } else { + if (passwordObfuscatorType === "Plain" || passwordObfuscatorType === "") { + return [password, ""]; + } else if (passwordObfuscatorType === "AES") { + return [encryptByAes(passwordObfuscatorKey, password), ""]; + } else if (passwordObfuscatorType === "DES") { + return [encryptByDes(passwordObfuscatorKey, password), ""]; + } + } +} + +function encryptByDes(key, password) { + const iv = CryptoJS.lib.WordArray.random(8); + return encrypt(CryptoJS.DES, key, iv, password); +} + +function encryptByAes(key, password) { + const iv = CryptoJS.lib.WordArray.random(16); + return encrypt(CryptoJS.AES, key, iv, password); +} + +function getRandomHexKey(length) { + const characters = "123456789abcdef"; + let key = ""; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + key += characters[randomIndex]; + } + return key; +} diff --git a/web/yarn.lock b/web/yarn.lock index 4d1220de..f6b61818 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -6101,6 +6101,11 @@ crypto-es@^1.2.2: resolved "https://registry.yarnpkg.com/crypto-es/-/crypto-es-1.2.7.tgz#754a6d52319a94fb4eb1f119297f17196b360f88" integrity sha512-UUqiVJ2gUuZFmbFsKmud3uuLcNP2+Opt+5ysmljycFCyhA0+T16XJmo1ev/t5kMChMqWh7IEvURNCqsg+SjZGQ== +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"