feat: add organization's PasswordObfuscator to obfuscate login API's password (#3260)

* feat: add PasswordObfuscator to the login API

* fix: change key error message

* fix: remove unnecessary change

* fix: fix one

* fix: fix two

* fix: fix three

* fix: fix five

* fix: disable organization update when key is invalid

* fix: fix six

* fix: use Form.Item to control key

* fix: update obfuscator.js

* Update obfuscator.go

* Update obfuscator.go

* Update auth.go

* fix: remove real-time key monitoring

---------

Co-authored-by: Yang Luo <hsluoyz@qq.com>
This commit is contained in:
ZhaoYP 2001
2024-10-03 10:38:37 +08:00
committed by GitHub
parent c0f22bae43
commit e1dea9f697
9 changed files with 250 additions and 0 deletions

View File

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

View File

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

76
util/obfuscator.go Normal file
View File

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

View File

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

View File

@ -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 (
<Card size="small" title={
@ -294,6 +311,34 @@ class OrganizationEditPage extends React.Component {
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Password obfuscator"), i18next.t("general:Password obfuscator - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}}
value={this.state.organization.passwordObfuscatorType}
onChange={(value => {this.updatePasswordObfuscator("type", value);})}>
{
[
{id: "Plain", name: "Plain"},
{id: "AES", name: "AES"},
{id: "DES", name: "DES"},
].map((obfuscatorType, index) => <Option key={index} value={obfuscatorType.id}>{obfuscatorType.name}</Option>)
}
</Select>
</Col>
</Row>
{
(this.state.organization.passwordObfuscatorType === "Plain" || this.state.organization.passwordObfuscatorType === "") ? null : (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Password obf key"), i18next.t("general:Password obf key - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.organization.passwordObfuscatorKey} onChange={(e) => {this.updatePasswordObfuscator("key", e.target.value);}} />
</Col>
</Row>)
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{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") {

View File

@ -35,6 +35,8 @@ class OrganizationListPage extends BaseListPage {
passwordType: "plain",
PasswordSalt: "",
passwordOptions: [],
passwordObfuscatorType: "Plain",
passwordObfuscatorKey: "",
countryCodes: ["US"],
defaultAvatar: `${Setting.StaticBaseUrl}/img/casbin.svg`,
defaultApplication: "",

View File

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

View File

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

View File

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