mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-08 00:50:28 +08:00
feat: support user migration from Keycloak using syncer (#645)
* feat: support user migration from Keycloak using syncer Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * feat: add more Keycloak columns Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * fix: requested changes Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com>
This commit is contained in:
@ -28,6 +28,8 @@ func GetCredManager(passwordType string) CredManager {
|
|||||||
return NewMd5UserSaltCredManager()
|
return NewMd5UserSaltCredManager()
|
||||||
} else if passwordType == "bcrypt" {
|
} else if passwordType == "bcrypt" {
|
||||||
return NewBcryptCredManager()
|
return NewBcryptCredManager()
|
||||||
|
} else if passwordType == "pbkdf2-salt" {
|
||||||
|
return NewPbkdf2SaltCredManager()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
39
cred/pbkdf2-salt.go
Normal file
39
cred/pbkdf2-salt.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2022 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 cred
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Pbkdf2SaltCredManager struct{}
|
||||||
|
|
||||||
|
func NewPbkdf2SaltCredManager() *Pbkdf2SaltCredManager {
|
||||||
|
cm := &Pbkdf2SaltCredManager{}
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *Pbkdf2SaltCredManager) GetHashedPassword(password string, userSalt string, organizationSalt string) string {
|
||||||
|
// https://www.keycloak.org/docs/latest/server_admin/index.html#password-database-compromised
|
||||||
|
decodedSalt, _ := base64.StdEncoding.DecodeString(userSalt)
|
||||||
|
res := pbkdf2.Key([]byte(password), decodedSalt, 27500, 64, sha256.New)
|
||||||
|
return base64.StdEncoding.EncodeToString(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *Pbkdf2SaltCredManager) IsPasswordCorrect(plainPwd string, hashedPwd string, userSalt string, organizationSalt string) bool {
|
||||||
|
return hashedPwd == cm.GetHashedPassword(plainPwd, userSalt, organizationSalt)
|
||||||
|
}
|
@ -25,6 +25,11 @@ import (
|
|||||||
|
|
||||||
type OriginalUser = User
|
type OriginalUser = User
|
||||||
|
|
||||||
|
type Credential struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
}
|
||||||
|
|
||||||
func (syncer *Syncer) getOriginalUsers() ([]*OriginalUser, error) {
|
func (syncer *Syncer) getOriginalUsers() ([]*OriginalUser, error) {
|
||||||
sql := fmt.Sprintf("select * from %s", syncer.getTable())
|
sql := fmt.Sprintf("select * from %s", syncer.getTable())
|
||||||
results, err := syncer.Adapter.Engine.QueryString(sql)
|
results, err := syncer.Adapter.Engine.QueryString(sql)
|
||||||
|
@ -15,9 +15,11 @@
|
|||||||
package object
|
package object
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/casdoor/casdoor/util"
|
"github.com/casdoor/casdoor/util"
|
||||||
)
|
)
|
||||||
@ -99,12 +101,18 @@ func (syncer *Syncer) setUserByKeyValue(user *User, key string, value string) {
|
|||||||
user.PasswordSalt = value
|
user.PasswordSalt = value
|
||||||
case "DisplayName":
|
case "DisplayName":
|
||||||
user.DisplayName = value
|
user.DisplayName = value
|
||||||
|
case "FirstName":
|
||||||
|
user.FirstName = value
|
||||||
|
case "LastName":
|
||||||
|
user.LastName = value
|
||||||
case "Avatar":
|
case "Avatar":
|
||||||
user.Avatar = syncer.getPartialAvatarUrl(value)
|
user.Avatar = syncer.getPartialAvatarUrl(value)
|
||||||
case "PermanentAvatar":
|
case "PermanentAvatar":
|
||||||
user.PermanentAvatar = value
|
user.PermanentAvatar = value
|
||||||
case "Email":
|
case "Email":
|
||||||
user.Email = value
|
user.Email = value
|
||||||
|
case "EmailVerified":
|
||||||
|
user.EmailVerified = util.ParseBool(value)
|
||||||
case "Phone":
|
case "Phone":
|
||||||
user.Phone = value
|
user.Phone = value
|
||||||
case "Location":
|
case "Location":
|
||||||
@ -167,6 +175,32 @@ func (syncer *Syncer) getOriginalUsersFromMap(results []map[string]string) []*Or
|
|||||||
for _, tableColumn := range syncer.TableColumns {
|
for _, tableColumn := range syncer.TableColumns {
|
||||||
syncer.setUserByKeyValue(originalUser, tableColumn.CasdoorName, result[tableColumn.Name])
|
syncer.setUserByKeyValue(originalUser, tableColumn.CasdoorName, result[tableColumn.Name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if syncer.Type == "Keycloak" {
|
||||||
|
// query and set password and password salt from credential table
|
||||||
|
sql := fmt.Sprintf("select * from credential where type = 'password' and user_id = '%s'", originalUser.Id)
|
||||||
|
credentialResult, _ := syncer.Adapter.Engine.QueryString(sql)
|
||||||
|
if len(credentialResult) > 0 {
|
||||||
|
credential := Credential{}
|
||||||
|
_ = json.Unmarshal([]byte(credentialResult[0]["SECRET_DATA"]), &credential)
|
||||||
|
originalUser.Password = credential.Value
|
||||||
|
originalUser.PasswordSalt = credential.Salt
|
||||||
|
}
|
||||||
|
// query and set signup application from user group table
|
||||||
|
sql = fmt.Sprintf("select name from keycloak_group where id = " +
|
||||||
|
"(select group_id as gid from user_group_membership where user_id = '%s')", originalUser.Id)
|
||||||
|
groupResult, _ := syncer.Adapter.Engine.QueryString(sql)
|
||||||
|
if len(groupResult) > 0 {
|
||||||
|
originalUser.SignupApplication = groupResult[0]["name"]
|
||||||
|
}
|
||||||
|
// create time
|
||||||
|
i, _ := strconv.ParseInt(originalUser.CreatedTime, 10, 64)
|
||||||
|
tm := time.Unix(i/int64(1000), 0)
|
||||||
|
originalUser.CreatedTime = tm.Format("2006-01-02T15:04:05+08:00")
|
||||||
|
// enable
|
||||||
|
originalUser.IsForbidden = !(result["ENABLED"] == "\x01")
|
||||||
|
}
|
||||||
|
|
||||||
users = append(users, originalUser)
|
users = append(users, originalUser)
|
||||||
}
|
}
|
||||||
return users
|
return users
|
||||||
|
@ -39,6 +39,7 @@ type User struct {
|
|||||||
Avatar string `xorm:"varchar(500)" json:"avatar"`
|
Avatar string `xorm:"varchar(500)" json:"avatar"`
|
||||||
PermanentAvatar string `xorm:"varchar(500)" json:"permanentAvatar"`
|
PermanentAvatar string `xorm:"varchar(500)" json:"permanentAvatar"`
|
||||||
Email string `xorm:"varchar(100) index" json:"email"`
|
Email string `xorm:"varchar(100) index" json:"email"`
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
Phone string `xorm:"varchar(100) index" json:"phone"`
|
Phone string `xorm:"varchar(100) index" json:"phone"`
|
||||||
Location string `xorm:"varchar(100)" json:"location"`
|
Location string `xorm:"varchar(100)" json:"location"`
|
||||||
Address []string `json:"address"`
|
Address []string `json:"address"`
|
||||||
|
@ -52,6 +52,10 @@ func ParseFloat(s string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ParseBool(s string) bool {
|
func ParseBool(s string) bool {
|
||||||
|
if s == "\x01" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
i := ParseInt(s)
|
i := ParseInt(s)
|
||||||
return i != 0
|
return i != 0
|
||||||
}
|
}
|
||||||
|
@ -155,7 +155,7 @@ class OrganizationEditPage extends React.Component {
|
|||||||
<Col span={22} >
|
<Col span={22} >
|
||||||
<Select virtual={false} style={{width: '100%'}} value={this.state.organization.passwordType} onChange={(value => {this.updateOrganizationField('passwordType', value);})}>
|
<Select virtual={false} style={{width: '100%'}} value={this.state.organization.passwordType} onChange={(value => {this.updateOrganizationField('passwordType', value);})}>
|
||||||
{
|
{
|
||||||
['plain', 'salt', 'md5-salt', 'bcrypt']
|
['plain', 'salt', 'md5-salt', 'bcrypt', 'pbkdf2-salt']
|
||||||
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
|
@ -654,3 +654,94 @@ export function getFromLink() {
|
|||||||
}
|
}
|
||||||
return from;
|
return from;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSyncerTableColumns(syncer) {
|
||||||
|
switch (syncer.type) {
|
||||||
|
case "Keycloak":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name":"ID",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"Id",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"USERNAME",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"Name",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"USERNAME",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"DisplayName",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"EMAIL",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"Email",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"EMAIL_VERIFIED",
|
||||||
|
"type":"boolean",
|
||||||
|
"casdoorName":"EmailVerified",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"FIRST_NAME",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"FirstName",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"LAST_NAME",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"LastName",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"CREATED_TIMESTAMP",
|
||||||
|
"type":"string",
|
||||||
|
"casdoorName":"CreatedTime",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"ENABLED",
|
||||||
|
"type":"boolean",
|
||||||
|
"casdoorName":"IsForbidden",
|
||||||
|
"isHashed":true,
|
||||||
|
"values":[
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
@ -117,9 +117,13 @@ class SyncerEditPage extends React.Component {
|
|||||||
{Setting.getLabel(i18next.t("provider:Type"), i18next.t("provider:Type - Tooltip"))} :
|
{Setting.getLabel(i18next.t("provider:Type"), i18next.t("provider:Type - Tooltip"))} :
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={22} >
|
<Col span={22} >
|
||||||
<Select virtual={false} style={{width: '100%'}} value={this.state.syncer.type} onChange={(value => {this.updateSyncerField('type', value);})}>
|
<Select virtual={false} style={{width: '100%'}} value={this.state.syncer.type} onChange={(value => {
|
||||||
|
this.updateSyncerField('type', value);
|
||||||
|
this.state.syncer["tableColumns"] = Setting.getSyncerTableColumns(this.state.syncer);
|
||||||
|
this.state.syncer.table = value === "Keycloak" ? "user_entity" : this.state.syncer.table;
|
||||||
|
})}>
|
||||||
{
|
{
|
||||||
['Database', 'LDAP']
|
['Database', 'LDAP', 'Keycloak']
|
||||||
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
@ -198,7 +202,8 @@ class SyncerEditPage extends React.Component {
|
|||||||
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :
|
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={22} >
|
<Col span={22} >
|
||||||
<Input value={this.state.syncer.table} onChange={e => {
|
<Input value={this.state.syncer.table}
|
||||||
|
disabled={this.state.syncer.type === "Keycloak"} onChange={e => {
|
||||||
this.updateSyncerField('table', e.target.value);
|
this.updateSyncerField('table', e.target.value);
|
||||||
}} />
|
}} />
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -98,9 +98,9 @@ class SyncerTableColumnTable extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<Select virtual={false} style={{width: '100%'}} value={text} onChange={(value => {this.updateField(table, index, 'casdoorName', value);})}>
|
<Select virtual={false} style={{width: '100%'}} value={text} onChange={(value => {this.updateField(table, index, 'casdoorName', value);})}>
|
||||||
{
|
{
|
||||||
['Name', 'CreatedTime', 'UpdatedTime', 'Id', 'Type', 'Password', 'PasswordSalt', 'DisplayName', 'Avatar', 'PermanentAvatar', 'Email', 'Phone',
|
['Name', 'CreatedTime', 'UpdatedTime', 'Id', 'Type', 'Password', 'PasswordSalt', 'DisplayName', 'FirstName', 'LastName', 'Avatar', 'PermanentAvatar',
|
||||||
'Location', 'Address', 'Affiliation', 'Title', 'IdCardType', 'IdCard', 'Homepage', 'Bio', 'Tag', 'Region', 'Language', 'Gender', 'Birthday',
|
'Email', 'EmailVerified', 'Phone', 'Location', 'Address', 'Affiliation', 'Title', 'IdCardType', 'IdCard', 'Homepage', 'Bio', 'Tag', 'Region',
|
||||||
'Education', 'Score', 'Ranking', 'IsDefaultAvatar', 'IsOnline', 'IsAdmin', 'IsGlobalAdmin', 'IsForbidden', 'IsDeleted', 'CreatedIp']
|
'Language', 'Gender', 'Birthday', 'Education', 'Score', 'Ranking', 'IsDefaultAvatar', 'IsOnline', 'IsAdmin', 'IsGlobalAdmin', 'IsForbidden', 'IsDeleted', 'CreatedIp']
|
||||||
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
|
Reference in New Issue
Block a user