Browse Source

LDAP user synchronization (#1478)

Lauris BH 3 years ago
parent
commit
524885dd65

+ 10 - 0
conf/app.ini

@@ -442,6 +442,16 @@ SCHEDULE = @every 24h
442 442
 ; Archives created more than OLDER_THAN ago are subject to deletion
443 443
 OLDER_THAN = 24h
444 444
 
445
+; Synchronize external user data (only LDAP user synchronization is supported)
446
+[cron.sync_external_users]
447
+; Syncronize external user data when starting server (default false)
448
+RUN_AT_START = false
449
+; Interval as a duration between each synchronization (default every 24h)
450
+SCHEDULE = @every 24h
451
+; Create new users, update existing user data and disable users that are not in external source anymore (default)
452
+;   or only create new users if UPDATE_EXISTING is set to false
453
+UPDATE_EXISTING = true
454
+
445 455
 [git]
446 456
 ; Disables highlight of added and removed changes
447 457
 DISABLE_DIFF_HIGHLIGHT = false

+ 23 - 18
models/login_source.go

@@ -140,11 +140,12 @@ func (cfg *OAuth2Config) ToDB() ([]byte, error) {
140 140
 
141 141
 // LoginSource represents an external way for authorizing users.
142 142
 type LoginSource struct {
143
-	ID        int64 `xorm:"pk autoincr"`
144
-	Type      LoginType
145
-	Name      string          `xorm:"UNIQUE"`
146
-	IsActived bool            `xorm:"INDEX NOT NULL DEFAULT false"`
147
-	Cfg       core.Conversion `xorm:"TEXT"`
143
+	ID            int64 `xorm:"pk autoincr"`
144
+	Type          LoginType
145
+	Name          string          `xorm:"UNIQUE"`
146
+	IsActived     bool            `xorm:"INDEX NOT NULL DEFAULT false"`
147
+	IsSyncEnabled bool            `xorm:"INDEX NOT NULL DEFAULT false"`
148
+	Cfg           core.Conversion `xorm:"TEXT"`
148 149
 
149 150
 	Created     time.Time `xorm:"-"`
150 151
 	CreatedUnix int64     `xorm:"INDEX"`
@@ -294,6 +295,10 @@ func CreateLoginSource(source *LoginSource) error {
294 295
 	} else if has {
295 296
 		return ErrLoginSourceAlreadyExist{source.Name}
296 297
 	}
298
+	// Synchronization is only aviable with LDAP for now
299
+	if !source.IsLDAP() {
300
+		source.IsSyncEnabled = false
301
+	}
297 302
 
298 303
 	_, err = x.Insert(source)
299 304
 	if err == nil && source.IsOAuth2() && source.IsActived {
@@ -405,8 +410,8 @@ func composeFullName(firstname, surname, username string) string {
405 410
 // LoginViaLDAP queries if login/password is valid against the LDAP directory pool,
406 411
 // and create a local user if success when enabled.
407 412
 func LoginViaLDAP(user *User, login, password string, source *LoginSource, autoRegister bool) (*User, error) {
408
-	username, fn, sn, mail, isAdmin, succeed := source.Cfg.(*LDAPConfig).SearchEntry(login, password, source.Type == LoginDLDAP)
409
-	if !succeed {
413
+	sr := source.Cfg.(*LDAPConfig).SearchEntry(login, password, source.Type == LoginDLDAP)
414
+	if sr == nil {
410 415
 		// User not in LDAP, do nothing
411 416
 		return nil, ErrUserNotExist{0, login, 0}
412 417
 	}
@@ -416,28 +421,28 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource, autoR
416 421
 	}
417 422
 
418 423
 	// Fallback.
419
-	if len(username) == 0 {
420
-		username = login
424
+	if len(sr.Username) == 0 {
425
+		sr.Username = login
421 426
 	}
422 427
 	// Validate username make sure it satisfies requirement.
423
-	if binding.AlphaDashDotPattern.MatchString(username) {
424
-		return nil, fmt.Errorf("Invalid pattern for attribute 'username' [%s]: must be valid alpha or numeric or dash(-_) or dot characters", username)
428
+	if binding.AlphaDashDotPattern.MatchString(sr.Username) {
429
+		return nil, fmt.Errorf("Invalid pattern for attribute 'username' [%s]: must be valid alpha or numeric or dash(-_) or dot characters", sr.Username)
425 430
 	}
426 431
 
427
-	if len(mail) == 0 {
428
-		mail = fmt.Sprintf("%s@localhost", username)
432
+	if len(sr.Mail) == 0 {
433
+		sr.Mail = fmt.Sprintf("%s@localhost", sr.Username)
429 434
 	}
430 435
 
431 436
 	user = &User{
432
-		LowerName:   strings.ToLower(username),
433
-		Name:        username,
434
-		FullName:    composeFullName(fn, sn, username),
435
-		Email:       mail,
437
+		LowerName:   strings.ToLower(sr.Username),
438
+		Name:        sr.Username,
439
+		FullName:    composeFullName(sr.Name, sr.Surname, sr.Username),
440
+		Email:       sr.Mail,
436 441
 		LoginType:   source.Type,
437 442
 		LoginSource: source.ID,
438 443
 		LoginName:   login,
439 444
 		IsActive:    true,
440
-		IsAdmin:     isAdmin,
445
+		IsAdmin:     sr.IsAdmin,
441 446
 	}
442 447
 	return user, CreateUser(user)
443 448
 }

+ 2 - 0
models/migrations/migrations.go

@@ -110,6 +110,8 @@ var migrations = []Migration{
110 110
 	NewMigration("add commit status table", addCommitStatus),
111 111
 	// v30 -> 31
112 112
 	NewMigration("add primary key to external login user", addExternalLoginUserPK),
113
+	// 31 -> 32
114
+	NewMigration("add field for login source synchronization", addLoginSourceSyncEnabledColumn),
113 115
 }
114 116
 
115 117
 // Migrate database to current version

+ 35 - 0
models/migrations/v31.go

@@ -0,0 +1,35 @@
1
+// Copyright 2017 The Gogs Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package migrations
6
+
7
+import (
8
+	"fmt"
9
+	"time"
10
+
11
+	"github.com/go-xorm/core"
12
+	"github.com/go-xorm/xorm"
13
+)
14
+
15
+func addLoginSourceSyncEnabledColumn(x *xorm.Engine) error {
16
+	// LoginSource see models/login_source.go
17
+	type LoginSource struct {
18
+		ID            int64 `xorm:"pk autoincr"`
19
+		Type          int
20
+		Name          string          `xorm:"UNIQUE"`
21
+		IsActived     bool            `xorm:"INDEX NOT NULL DEFAULT false"`
22
+		IsSyncEnabled bool            `xorm:"INDEX NOT NULL DEFAULT false"`
23
+		Cfg           core.Conversion `xorm:"TEXT"`
24
+
25
+		Created     time.Time `xorm:"-"`
26
+		CreatedUnix int64     `xorm:"INDEX"`
27
+		Updated     time.Time `xorm:"-"`
28
+		UpdatedUnix int64     `xorm:"INDEX"`
29
+	}
30
+
31
+	if err := x.Sync2(new(LoginSource)); err != nil {
32
+		return fmt.Errorf("Sync2: %v", err)
33
+	}
34
+	return nil
35
+}

+ 127 - 0
models/user.go

@@ -50,6 +50,8 @@ const (
50 50
 	UserTypeOrganization
51 51
 )
52 52
 
53
+const syncExternalUsers = "sync_external_users"
54
+
53 55
 var (
54 56
 	// ErrUserNotKeyOwner user does not own this key error
55 57
 	ErrUserNotKeyOwner = errors.New("User does not own this public key")
@@ -1322,3 +1324,128 @@ func GetWatchedRepos(userID int64, private bool) ([]*Repository, error) {
1322 1324
 	}
1323 1325
 	return repos, nil
1324 1326
 }
1327
+
1328
+// SyncExternalUsers is used to synchronize users with external authorization source
1329
+func SyncExternalUsers() {
1330
+	if taskStatusTable.IsRunning(syncExternalUsers) {
1331
+		return
1332
+	}
1333
+	taskStatusTable.Start(syncExternalUsers)
1334
+	defer taskStatusTable.Stop(syncExternalUsers)
1335
+
1336
+	log.Trace("Doing: SyncExternalUsers")
1337
+
1338
+	ls, err := LoginSources()
1339
+	if err != nil {
1340
+		log.Error(4, "SyncExternalUsers: %v", err)
1341
+		return
1342
+	}
1343
+
1344
+	updateExisting := setting.Cron.SyncExternalUsers.UpdateExisting
1345
+
1346
+	for _, s := range ls {
1347
+		if !s.IsActived || !s.IsSyncEnabled {
1348
+			continue
1349
+		}
1350
+		if s.IsLDAP() {
1351
+			log.Trace("Doing: SyncExternalUsers[%s]", s.Name)
1352
+
1353
+			var existingUsers []int64
1354
+
1355
+			// Find all users with this login type
1356
+			var users []User
1357
+			x.Where("login_type = ?", LoginLDAP).
1358
+				And("login_source = ?", s.ID).
1359
+				Find(&users)
1360
+
1361
+			sr := s.LDAP().SearchEntries()
1362
+
1363
+			for _, su := range sr {
1364
+				if len(su.Username) == 0 {
1365
+					continue
1366
+				}
1367
+
1368
+				if len(su.Mail) == 0 {
1369
+					su.Mail = fmt.Sprintf("%s@localhost", su.Username)
1370
+				}
1371
+
1372
+				var usr *User
1373
+				// Search for existing user
1374
+				for _, du := range users {
1375
+					if du.LowerName == strings.ToLower(su.Username) {
1376
+						usr = &du
1377
+						break
1378
+					}
1379
+				}
1380
+
1381
+				fullName := composeFullName(su.Name, su.Surname, su.Username)
1382
+				// If no existing user found, create one
1383
+				if usr == nil {
1384
+					log.Trace("SyncExternalUsers[%s]: Creating user %s", s.Name, su.Username)
1385
+
1386
+					usr = &User{
1387
+						LowerName:   strings.ToLower(su.Username),
1388
+						Name:        su.Username,
1389
+						FullName:    fullName,
1390
+						LoginType:   s.Type,
1391
+						LoginSource: s.ID,
1392
+						LoginName:   su.Username,
1393
+						Email:       su.Mail,
1394
+						IsAdmin:     su.IsAdmin,
1395
+						IsActive:    true,
1396
+					}
1397
+
1398
+					err = CreateUser(usr)
1399
+					if err != nil {
1400
+						log.Error(4, "SyncExternalUsers[%s]: Error creating user %s: %v", s.Name, su.Username, err)
1401
+					}
1402
+				} else if updateExisting {
1403
+					existingUsers = append(existingUsers, usr.ID)
1404
+					// Check if user data has changed
1405
+					if (len(s.LDAP().AdminFilter) > 0 && usr.IsAdmin != su.IsAdmin) ||
1406
+						strings.ToLower(usr.Email) != strings.ToLower(su.Mail) ||
1407
+						usr.FullName != fullName ||
1408
+						!usr.IsActive {
1409
+
1410
+						log.Trace("SyncExternalUsers[%s]: Updating user %s", s.Name, usr.Name)
1411
+
1412
+						usr.FullName = fullName
1413
+						usr.Email = su.Mail
1414
+						// Change existing admin flag only if AdminFilter option is set
1415
+						if len(s.LDAP().AdminFilter) > 0 {
1416
+							usr.IsAdmin = su.IsAdmin
1417
+						}
1418
+						usr.IsActive = true
1419
+
1420
+						err = UpdateUser(usr)
1421
+						if err != nil {
1422
+							log.Error(4, "SyncExternalUsers[%s]: Error updating user %s: %v", s.Name, usr.Name, err)
1423
+						}
1424
+					}
1425
+				}
1426
+			}
1427
+
1428
+			// Deactivate users not present in LDAP
1429
+			if updateExisting {
1430
+				for _, usr := range users {
1431
+					found := false
1432
+					for _, uid := range existingUsers {
1433
+						if usr.ID == uid {
1434
+							found = true
1435
+							break
1436
+						}
1437
+					}
1438
+					if !found {
1439
+						log.Trace("SyncExternalUsers[%s]: Deactivating user %s", s.Name, usr.Name)
1440
+
1441
+						usr.IsActive = false
1442
+						err = UpdateUser(&usr)
1443
+						if err != nil {
1444
+							log.Error(4, "SyncExternalUsers[%s]: Error deactivating user %s: %v", s.Name, usr.Name, err)
1445
+						}
1446
+					}
1447
+				}
1448
+			}
1449
+		}
1450
+	}
1451
+}

+ 1 - 0
modules/auth/auth_form.go

@@ -28,6 +28,7 @@ type AuthenticationForm struct {
28 28
 	Filter                        string
29 29
 	AdminFilter                   string
30 30
 	IsActive                      bool
31
+	IsSyncEnabled                 bool
31 32
 	SMTPAuth                      string
32 33
 	SMTPHost                      string
33 34
 	SMTPPort                      int

+ 97 - 28
modules/auth/ldap/ldap.go

@@ -47,6 +47,15 @@ type Source struct {
47 47
 	Enabled           bool   // if this source is disabled
48 48
 }
49 49
 
50
+// SearchResult : user data
51
+type SearchResult struct {
52
+	Username string // Username
53
+	Name     string // Name
54
+	Surname  string // Surname
55
+	Mail     string // E-mail address
56
+	IsAdmin  bool   // if user is administrator
57
+}
58
+
50 59
 func (ls *Source) sanitizedUserQuery(username string) (string, bool) {
51 60
 	// See http://tools.ietf.org/search/rfc4515
52 61
 	badCharacters := "\x00()*\\"
@@ -149,18 +158,39 @@ func bindUser(l *ldap.Conn, userDN, passwd string) error {
149 158
 	return err
150 159
 }
151 160
 
161
+func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool {
162
+	if len(ls.AdminFilter) > 0 {
163
+		log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
164
+		search := ldap.NewSearchRequest(
165
+			userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
166
+			[]string{ls.AttributeName},
167
+			nil)
168
+
169
+		sr, err := l.Search(search)
170
+
171
+		if err != nil {
172
+			log.Error(4, "LDAP Admin Search failed unexpectedly! (%v)", err)
173
+		} else if len(sr.Entries) < 1 {
174
+			log.Error(4, "LDAP Admin Search failed")
175
+		} else {
176
+			return true
177
+		}
178
+	}
179
+	return false
180
+}
181
+
152 182
 // SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
153
-func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, string, string, string, bool, bool) {
183
+func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
154 184
 	// See https://tools.ietf.org/search/rfc4513#section-5.1.2
155 185
 	if len(passwd) == 0 {
156 186
 		log.Debug("Auth. failed for %s, password cannot be empty")
157
-		return "", "", "", "", false, false
187
+		return nil
158 188
 	}
159 189
 	l, err := dial(ls)
160 190
 	if err != nil {
161 191
 		log.Error(4, "LDAP Connect error, %s:%v", ls.Host, err)
162 192
 		ls.Enabled = false
163
-		return "", "", "", "", false, false
193
+		return nil
164 194
 	}
165 195
 	defer l.Close()
166 196
 
@@ -171,7 +201,7 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, str
171 201
 		var ok bool
172 202
 		userDN, ok = ls.sanitizedUserDN(name)
173 203
 		if !ok {
174
-			return "", "", "", "", false, false
204
+			return nil
175 205
 		}
176 206
 	} else {
177 207
 		log.Trace("LDAP will use BindDN.")
@@ -179,7 +209,7 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, str
179 209
 		var found bool
180 210
 		userDN, found = ls.findUserDN(l, name)
181 211
 		if !found {
182
-			return "", "", "", "", false, false
212
+			return nil
183 213
 		}
184 214
 	}
185 215
 
@@ -187,13 +217,13 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, str
187 217
 		// binds user (checking password) before looking-up attributes in user context
188 218
 		err = bindUser(l, userDN, passwd)
189 219
 		if err != nil {
190
-			return "", "", "", "", false, false
220
+			return nil
191 221
 		}
192 222
 	}
193 223
 
194 224
 	userFilter, ok := ls.sanitizedUserQuery(name)
195 225
 	if !ok {
196
-		return "", "", "", "", false, false
226
+		return nil
197 227
 	}
198 228
 
199 229
 	log.Trace("Fetching attributes '%v', '%v', '%v', '%v' with filter %s and base %s", ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, userFilter, userDN)
@@ -205,7 +235,7 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, str
205 235
 	sr, err := l.Search(search)
206 236
 	if err != nil {
207 237
 		log.Error(4, "LDAP Search failed unexpectedly! (%v)", err)
208
-		return "", "", "", "", false, false
238
+		return nil
209 239
 	} else if len(sr.Entries) < 1 {
210 240
 		if directBind {
211 241
 			log.Error(4, "User filter inhibited user login.")
@@ -213,39 +243,78 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) (string, str
213 243
 			log.Error(4, "LDAP Search failed unexpectedly! (0 entries)")
214 244
 		}
215 245
 
216
-		return "", "", "", "", false, false
246
+		return nil
217 247
 	}
218 248
 
219 249
 	username := sr.Entries[0].GetAttributeValue(ls.AttributeUsername)
220 250
 	firstname := sr.Entries[0].GetAttributeValue(ls.AttributeName)
221 251
 	surname := sr.Entries[0].GetAttributeValue(ls.AttributeSurname)
222 252
 	mail := sr.Entries[0].GetAttributeValue(ls.AttributeMail)
253
+	isAdmin := checkAdmin(l, ls, userDN)
223 254
 
224
-	isAdmin := false
225
-	if len(ls.AdminFilter) > 0 {
226
-		log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
227
-		search = ldap.NewSearchRequest(
228
-			userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
229
-			[]string{ls.AttributeName},
230
-			nil)
231
-
232
-		sr, err = l.Search(search)
255
+	if !directBind && ls.AttributesInBind {
256
+		// binds user (checking password) after looking-up attributes in BindDN context
257
+		err = bindUser(l, userDN, passwd)
233 258
 		if err != nil {
234
-			log.Error(4, "LDAP Admin Search failed unexpectedly! (%v)", err)
235
-		} else if len(sr.Entries) < 1 {
236
-			log.Error(4, "LDAP Admin Search failed")
237
-		} else {
238
-			isAdmin = true
259
+			return nil
239 260
 		}
240 261
 	}
241 262
 
242
-	if !directBind && ls.AttributesInBind {
243
-		// binds user (checking password) after looking-up attributes in BindDN context
244
-		err = bindUser(l, userDN, passwd)
263
+	return &SearchResult{
264
+		Username: username,
265
+		Name:     firstname,
266
+		Surname:  surname,
267
+		Mail:     mail,
268
+		IsAdmin:  isAdmin,
269
+	}
270
+}
271
+
272
+// SearchEntries : search an LDAP source for all users matching userFilter
273
+func (ls *Source) SearchEntries() []*SearchResult {
274
+	l, err := dial(ls)
275
+	if err != nil {
276
+		log.Error(4, "LDAP Connect error, %s:%v", ls.Host, err)
277
+		ls.Enabled = false
278
+		return nil
279
+	}
280
+	defer l.Close()
281
+
282
+	if ls.BindDN != "" && ls.BindPassword != "" {
283
+		err := l.Bind(ls.BindDN, ls.BindPassword)
245 284
 		if err != nil {
246
-			return "", "", "", "", false, false
285
+			log.Debug("Failed to bind as BindDN[%s]: %v", ls.BindDN, err)
286
+			return nil
287
+		}
288
+		log.Trace("Bound as BindDN %s", ls.BindDN)
289
+	} else {
290
+		log.Trace("Proceeding with anonymous LDAP search.")
291
+	}
292
+
293
+	userFilter := fmt.Sprintf(ls.Filter, "*")
294
+
295
+	log.Trace("Fetching attributes '%v', '%v', '%v', '%v' with filter %s and base %s", ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, userFilter, ls.UserBase)
296
+	search := ldap.NewSearchRequest(
297
+		ls.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
298
+		[]string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail},
299
+		nil)
300
+
301
+	sr, err := l.Search(search)
302
+	if err != nil {
303
+		log.Error(4, "LDAP Search failed unexpectedly! (%v)", err)
304
+		return nil
305
+	}
306
+
307
+	result := make([]*SearchResult, len(sr.Entries))
308
+
309
+	for i, v := range sr.Entries {
310
+		result[i] = &SearchResult{
311
+			Username: v.GetAttributeValue(ls.AttributeUsername),
312
+			Name:     v.GetAttributeValue(ls.AttributeName),
313
+			Surname:  v.GetAttributeValue(ls.AttributeSurname),
314
+			Mail:     v.GetAttributeValue(ls.AttributeMail),
315
+			IsAdmin:  checkAdmin(l, ls, v.DN),
247 316
 		}
248 317
 	}
249 318
 
250
-	return username, firstname, surname, mail, isAdmin, true
319
+	return result
251 320
 }

+ 11 - 0
modules/cron/cron.go

@@ -66,6 +66,17 @@ func NewContext() {
66 66
 			go models.DeleteOldRepositoryArchives()
67 67
 		}
68 68
 	}
69
+	if setting.Cron.SyncExternalUsers.Enabled {
70
+		entry, err = c.AddFunc("Synchronize external users", setting.Cron.SyncExternalUsers.Schedule, models.SyncExternalUsers)
71
+		if err != nil {
72
+			log.Fatal(4, "Cron[Synchronize external users]: %v", err)
73
+		}
74
+		if setting.Cron.SyncExternalUsers.RunAtStart {
75
+			entry.Prev = time.Now()
76
+			entry.ExecTimes++
77
+			go models.SyncExternalUsers()
78
+		}
79
+	}
69 80
 	c.Start()
70 81
 }
71 82
 

+ 17 - 0
modules/setting/setting.go

@@ -336,6 +336,12 @@ var (
336 336
 			Schedule   string
337 337
 			OlderThan  time.Duration
338 338
 		} `ini:"cron.archive_cleanup"`
339
+		SyncExternalUsers struct {
340
+			Enabled        bool
341
+			RunAtStart     bool
342
+			Schedule       string
343
+			UpdateExisting bool
344
+		} `ini:"cron.sync_external_users"`
339 345
 	}{
340 346
 		UpdateMirror: struct {
341 347
 			Enabled    bool
@@ -379,6 +385,17 @@ var (
379 385
 			Schedule:   "@every 24h",
380 386
 			OlderThan:  24 * time.Hour,
381 387
 		},
388
+		SyncExternalUsers: struct {
389
+			Enabled        bool
390
+			RunAtStart     bool
391
+			Schedule       string
392
+			UpdateExisting bool
393
+		}{
394
+			Enabled:        true,
395
+			RunAtStart:     false,
396
+			Schedule:       "@every 24h",
397
+			UpdateExisting: true,
398
+		},
382 399
 	}
383 400
 
384 401
 	// Git settings

+ 3 - 1
options/locale/locale_en-US.ini

@@ -1065,7 +1065,8 @@ dashboard.resync_all_hooks = Resync pre-receive, update and post-receive hooks o
1065 1065
 dashboard.resync_all_hooks_success = All repositories' pre-receive, update and post-receive hooks have been resynced successfully.
1066 1066
 dashboard.reinit_missing_repos = Reinitialize all lost Git repositories for which records exist
1067 1067
 dashboard.reinit_missing_repos_success = All lost Git repositories for which records existed have been reinitialized successfully.
1068
-
1068
+dashboard.sync_external_users = Synchronize external user data
1069
+dashboard.sync_external_users_started = External user synchronization started
1069 1070
 dashboard.server_uptime = Server Uptime
1070 1071
 dashboard.current_goroutine = Current Goroutines
1071 1072
 dashboard.current_memory_usage = Current Memory Usage
@@ -1147,6 +1148,7 @@ auths.new = Add New Source
1147 1148
 auths.name = Name
1148 1149
 auths.type = Type
1149 1150
 auths.enabled = Enabled
1151
+auths.syncenabled = Enable user synchronization
1150 1152
 auths.updated = Updated
1151 1153
 auths.auth_type = Authentication Type
1152 1154
 auths.auth_name = Authentication Name

+ 4 - 0
routers/admin/admin.go

@@ -121,6 +121,7 @@ const (
121 121
 	syncSSHAuthorizedKey
122 122
 	syncRepositoryUpdateHook
123 123
 	reinitMissingRepository
124
+	syncExternalUsers
124 125
 )
125 126
 
126 127
 // Dashboard show admin panel dashboard
@@ -157,6 +158,9 @@ func Dashboard(ctx *context.Context) {
157 158
 		case reinitMissingRepository:
158 159
 			success = ctx.Tr("admin.dashboard.reinit_missing_repos_success")
159 160
 			err = models.ReinitMissingRepositories()
161
+		case syncExternalUsers:
162
+			success = ctx.Tr("admin.dashboard.sync_external_users_started")
163
+			go models.SyncExternalUsers()
160 164
 		}
161 165
 
162 166
 		if err != nil {

+ 7 - 4
routers/admin/auths.go

@@ -74,6 +74,7 @@ func NewAuthSource(ctx *context.Context) {
74 74
 	ctx.Data["CurrentSecurityProtocol"] = models.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted]
75 75
 	ctx.Data["smtp_auth"] = "PLAIN"
76 76
 	ctx.Data["is_active"] = true
77
+	ctx.Data["is_sync_enabled"] = true
77 78
 	ctx.Data["AuthSources"] = authSources
78 79
 	ctx.Data["SecurityProtocols"] = securityProtocols
79 80
 	ctx.Data["SMTPAuths"] = models.SMTPAuths
@@ -186,10 +187,11 @@ func NewAuthSourcePost(ctx *context.Context, form auth.AuthenticationForm) {
186 187
 	}
187 188
 
188 189
 	if err := models.CreateLoginSource(&models.LoginSource{
189
-		Type:      models.LoginType(form.Type),
190
-		Name:      form.Name,
191
-		IsActived: form.IsActive,
192
-		Cfg:       config,
190
+		Type:          models.LoginType(form.Type),
191
+		Name:          form.Name,
192
+		IsActived:     form.IsActive,
193
+		IsSyncEnabled: form.IsSyncEnabled,
194
+		Cfg:           config,
193 195
 	}); err != nil {
194 196
 		if models.IsErrLoginSourceAlreadyExist(err) {
195 197
 			ctx.Data["Err_Name"] = true
@@ -273,6 +275,7 @@ func EditAuthSourcePost(ctx *context.Context, form auth.AuthenticationForm) {
273 275
 
274 276
 	source.Name = form.Name
275 277
 	source.IsActived = form.IsActive
278
+	source.IsSyncEnabled = form.IsSyncEnabled
276 279
 	source.Cfg = config
277 280
 	if err := models.UpdateSource(source); err != nil {
278 281
 		if models.IsErrOpenIDConnectInitialize(err) {

+ 8 - 0
templates/admin/auth/edit.tmpl

@@ -211,6 +211,14 @@
211 211
 						<input name="skip_verify" type="checkbox" {{if .Source.SkipVerify}}checked{{end}}>
212 212
 					</div>
213 213
 				</div>
214
+				{{if .Source.IsLDAP}}
215
+				<div class="inline field">
216
+					<div class="ui checkbox">
217
+						<label><strong>{{.i18n.Tr "admin.auths.syncenabled"}}</strong></label>
218
+						<input name="is_sync_enabled" type="checkbox" {{if .Source.IsSyncEnabled}}checked{{end}}>
219
+					</div>
220
+				</div>
221
+				{{end}}
214 222
 				<div class="inline field">
215 223
 					<div class="ui checkbox">
216 224
 						<label><strong>{{.i18n.Tr "admin.auths.activated"}}</strong></label>

+ 6 - 0
templates/admin/auth/new.tmpl

@@ -61,6 +61,12 @@
61 61
 						<input name="skip_verify" type="checkbox" {{if .skip_verify}}checked{{end}}>
62 62
 					</div>
63 63
 				</div>
64
+				<div class="ldap inline field {{if not (eq .type 2)}}hide{{end}}">
65
+					<div class="ui checkbox">
66
+						<label><strong>{{.i18n.Tr "admin.auths.syncenabled"}}</strong></label>
67
+						<input name="is_sync_enabled" type="checkbox" {{if .is_sync_enabled}}checked{{end}}>
68
+					</div>
69
+				</div>
64 70
 				<div class="inline field">
65 71
 					<div class="ui checkbox">
66 72
 						<label><strong>{{.i18n.Tr "admin.auths.activated"}}</strong></label>

+ 4 - 0
templates/admin/dashboard.tmpl

@@ -45,6 +45,10 @@
45 45
 						<td>{{.i18n.Tr "admin.dashboard.reinit_missing_repos"}}</td>
46 46
 						<td><i class="fa fa-caret-square-o-right"></i> <a href="{{AppSubUrl}}/admin?op=7">{{.i18n.Tr "admin.dashboard.operation_run"}}</a></td>
47 47
 					</tr>
48
+					<tr>
49
+						<td>{{.i18n.Tr "admin.dashboard.sync_external_users"}}</td>
50
+						<td><i class="fa fa-caret-square-o-right"></i> <a href="{{AppSubUrl}}/admin?op=8">{{.i18n.Tr "admin.dashboard.operation_run"}}</a></td>
51
+					</tr>
48 52
 				</tbody>
49 53
 			</table>
50 54
 		</div>