mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-07-04 17:06:49 +00:00
Adds LDAP auth support
This commit is contained in:
@ -40,7 +40,7 @@ func (s *testsuite) SetupTest() {
|
||||
}).AddRow(
|
||||
10,
|
||||
100,
|
||||
TypePassword,
|
||||
TypeLocal,
|
||||
"abc123",
|
||||
)
|
||||
}
|
||||
@ -54,7 +54,7 @@ func TestExampleTestSuite(t *testing.T) {
|
||||
func assertModel(t *testing.T, m Model) {
|
||||
assert.Equal(t, uint(10), m.ID)
|
||||
assert.Equal(t, uint(100), m.UserID)
|
||||
assert.Equal(t, TypePassword, m.Type)
|
||||
assert.Equal(t, TypeLocal, m.Type)
|
||||
assert.Equal(t, "abc123", m.Secret)
|
||||
}
|
||||
|
||||
@ -83,10 +83,10 @@ func (s *testsuite) TestGetByUserIDType() {
|
||||
|
||||
s.mock.
|
||||
ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "auth" WHERE user_id = $1 AND type = $2 AND "auth"."is_deleted" = $3 ORDER BY "auth"."id" LIMIT $4`)).
|
||||
WithArgs(100, TypePassword, 0, 1).
|
||||
WithArgs(100, TypeLocal, 0, 1).
|
||||
WillReturnRows(s.singleRow)
|
||||
|
||||
m, err := GetByUserIDType(100, TypePassword)
|
||||
m, err := GetByUserIDType(100, TypeLocal)
|
||||
require.NoError(s.T(), err)
|
||||
require.NoError(s.T(), s.mock.ExpectationsWereMet())
|
||||
assertModel(s.T(), m)
|
||||
@ -103,7 +103,7 @@ func (s *testsuite) TestSave() {
|
||||
sqlmock.AnyArg(),
|
||||
0,
|
||||
100,
|
||||
TypePassword,
|
||||
TypeLocal,
|
||||
"abc123",
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11"))
|
||||
@ -112,7 +112,7 @@ func (s *testsuite) TestSave() {
|
||||
// New model
|
||||
m := Model{
|
||||
UserID: 100,
|
||||
Type: TypePassword,
|
||||
Type: TypeLocal,
|
||||
Secret: "abc123",
|
||||
}
|
||||
err := m.Save()
|
||||
@ -127,7 +127,7 @@ func (s *testsuite) TestSetPassword() {
|
||||
m := Model{UserID: 100}
|
||||
err := m.SetPassword("abc123")
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), TypePassword, m.Type)
|
||||
assert.Equal(s.T(), TypeLocal, m.Type)
|
||||
assert.Greater(s.T(), len(m.Secret), 15)
|
||||
|
||||
}
|
||||
@ -143,10 +143,10 @@ func (s *testsuite) TestValidateSecret() {
|
||||
require.NoError(s.T(), err)
|
||||
err = m.ValidateSecret("this is not the password")
|
||||
assert.NotNil(s.T(), err)
|
||||
assert.Equal(s.T(), "Invalid Password", err.Error())
|
||||
assert.Equal(s.T(), "Invalid Credentials", err.Error())
|
||||
|
||||
m.Type = "not a valid type"
|
||||
err = m.ValidateSecret("abc123")
|
||||
assert.NotNil(s.T(), err)
|
||||
assert.Equal(s.T(), "Could not validate Secret, auth type is not a Password", err.Error())
|
||||
assert.Equal(s.T(), "Could not validate Secret, auth type is not Local", err.Error())
|
||||
}
|
||||
|
96
backend/internal/entity/auth/ldap.go
Normal file
96
backend/internal/entity/auth/ldap.go
Normal file
@ -0,0 +1,96 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"npm/internal/entity/setting"
|
||||
"npm/internal/logger"
|
||||
|
||||
ldap3 "github.com/go-ldap/ldap/v3"
|
||||
"github.com/rotisserie/eris"
|
||||
)
|
||||
|
||||
// LDAPUser is the LDAP User
|
||||
type LDAPUser struct {
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// LDAPAuthenticate will use ldap to authenticate with user/pass
|
||||
func LDAPAuthenticate(identity, password string) (*LDAPUser, error) {
|
||||
ldapSettings, err := setting.GetLDAPSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dn := strings.Replace(ldapSettings.UserDN, "{{USERNAME}}", identity, 1)
|
||||
conn, err := ldapConnect(ldapSettings.Host, dn, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// nolint: errcheck, gosec
|
||||
defer conn.Close()
|
||||
return ldapSearchUser(conn, ldapSettings, identity)
|
||||
}
|
||||
|
||||
// Attempt ldap connection
|
||||
func ldapConnect(host, dn, password string) (*ldap3.Conn, error) {
|
||||
var conn *ldap3.Conn
|
||||
var err error
|
||||
|
||||
if conn, err = ldap3.DialURL(fmt.Sprintf("ldap://%s", host)); err != nil {
|
||||
logger.Error("LdapError", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("LDAP Logging in with: %s", dn)
|
||||
if err := conn.Bind(dn, password); err != nil {
|
||||
if !strings.Contains(err.Error(), "Invalid Credentials") {
|
||||
logger.Error("LDAPAuthError", err)
|
||||
}
|
||||
// nolint: gosec, errcheck
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("LDAP Login Successful")
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func ldapSearchUser(l *ldap3.Conn, ldapSettings setting.LDAPSettings, username string) (*LDAPUser, error) {
|
||||
// Search for the given username
|
||||
searchRequest := ldap3.NewSearchRequest(
|
||||
ldapSettings.BaseDN,
|
||||
ldap3.ScopeWholeSubtree,
|
||||
ldap3.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
strings.Replace(ldapSettings.SelfFilter, "{{USERNAME}}", username, 1),
|
||||
nil, // []string{"name"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := l.Search(searchRequest)
|
||||
if err != nil {
|
||||
logger.Error("LdapError", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(sr.Entries) < 1 {
|
||||
return nil, eris.New("No user found in LDAP search")
|
||||
} else if len(sr.Entries) > 1 {
|
||||
j, _ := json.Marshal(sr)
|
||||
logger.Debug("LDAP Search Results: %s", j)
|
||||
return nil, eris.Errorf("Too many LDAP results returned in LDAP search: %d", len(sr.Entries))
|
||||
}
|
||||
|
||||
return &LDAPUser{
|
||||
Username: strings.ToLower(username),
|
||||
Name: sr.Entries[0].GetAttributeValue(ldapSettings.NameProperty),
|
||||
Email: strings.ToLower(sr.Entries[0].GetAttributeValue(ldapSettings.EmailProperty)),
|
||||
}, nil
|
||||
}
|
@ -11,7 +11,7 @@ func GetByID(id int) (Model, error) {
|
||||
return m, err
|
||||
}
|
||||
|
||||
// GetByUserIDType finds a user by email
|
||||
// GetByUserIDType finds a user by id and type
|
||||
func GetByUserIDType(userID uint, authType string) (Model, error) {
|
||||
var auth Model
|
||||
db := database.GetDB()
|
||||
@ -21,3 +21,14 @@ func GetByUserIDType(userID uint, authType string) (Model, error) {
|
||||
First(&auth)
|
||||
return auth, result.Error
|
||||
}
|
||||
|
||||
// GetByUserIDType finds a user by id and type
|
||||
func GetByIdenityType(identity string, authType string) (Model, error) {
|
||||
var auth Model
|
||||
db := database.GetDB()
|
||||
result := db.
|
||||
Where("identity = ?", identity).
|
||||
Where("type = ?", authType).
|
||||
First(&auth)
|
||||
return auth, result.Error
|
||||
}
|
||||
|
@ -8,17 +8,20 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Auth types
|
||||
const (
|
||||
// TypePassword is the Password Type
|
||||
TypePassword = "password"
|
||||
TypeLocal = "local"
|
||||
TypeLDAP = "ldap"
|
||||
TypeOIDC = "oidc"
|
||||
)
|
||||
|
||||
// Model is the model
|
||||
type Model struct {
|
||||
model.ModelBase
|
||||
UserID uint `json:"user_id" gorm:"column:user_id"`
|
||||
Type string `json:"type" gorm:"column:type;default:password"`
|
||||
Secret string `json:"secret,omitempty" gorm:"column:secret"`
|
||||
UserID uint `json:"user_id" gorm:"column:user_id"`
|
||||
Type string `json:"type" gorm:"column:type;default:local"`
|
||||
Identity string `json:"identity,omitempty" gorm:"column:identity"`
|
||||
Secret string `json:"secret,omitempty" gorm:"column:secret"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name used by gorm
|
||||
@ -48,7 +51,7 @@ func (m *Model) SetPassword(password string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
m.Type = TypePassword
|
||||
m.Type = TypeLocal
|
||||
m.Secret = string(hash)
|
||||
|
||||
return nil
|
||||
@ -56,13 +59,13 @@ func (m *Model) SetPassword(password string) error {
|
||||
|
||||
// ValidateSecret will check if a given secret matches the encrypted secret
|
||||
func (m *Model) ValidateSecret(secret string) error {
|
||||
if m.Type != TypePassword {
|
||||
return eris.New("Could not validate Secret, auth type is not a Password")
|
||||
if m.Type != TypeLocal {
|
||||
return eris.New("Could not validate Secret, auth type is not Local")
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(m.Secret), []byte(secret))
|
||||
if err != nil {
|
||||
return eris.New("Invalid Password")
|
||||
return eris.New("Invalid Credentials")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
20
backend/internal/entity/setting/auth_methods.go
Normal file
20
backend/internal/entity/setting/auth_methods.go
Normal file
@ -0,0 +1,20 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// GetAuthMethods returns the authentication methods enabled for this site
|
||||
func GetAuthMethods() ([]string, error) {
|
||||
var l []string
|
||||
var m Model
|
||||
if err := m.LoadByName("auth-methods"); err != nil {
|
||||
return l, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil {
|
||||
return l, err
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
32
backend/internal/entity/setting/ldap.go
Normal file
32
backend/internal/entity/setting/ldap.go
Normal file
@ -0,0 +1,32 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// LDAPSettings are the settings for LDAP that come from
|
||||
// the `ldap-auth` setting value
|
||||
type LDAPSettings struct {
|
||||
Host string `json:"host"`
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserDN string `json:"user_dn"`
|
||||
EmailProperty string `json:"email_property"`
|
||||
NameProperty string `json:"name_property"`
|
||||
SelfFilter string `json:"self_filter"`
|
||||
AutoCreateUser bool `json:"auto_create_user"`
|
||||
}
|
||||
|
||||
// GetLDAPSettings will return the LDAP settings
|
||||
func GetLDAPSettings() (LDAPSettings, error) {
|
||||
var l LDAPSettings
|
||||
var m Model
|
||||
if err := m.LoadByName("ldap-auth"); err != nil {
|
||||
return l, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil {
|
||||
return l, err
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
@ -41,14 +41,12 @@ func (s *testsuite) SetupTest() {
|
||||
s.singleRow = sqlmock.NewRows([]string{
|
||||
"id",
|
||||
"name",
|
||||
"nickname",
|
||||
"email",
|
||||
"is_disabled",
|
||||
"is_system",
|
||||
}).AddRow(
|
||||
10,
|
||||
"John Doe",
|
||||
"Jonny",
|
||||
"jon@example.com",
|
||||
false,
|
||||
false,
|
||||
@ -74,14 +72,12 @@ func (s *testsuite) SetupTest() {
|
||||
s.listRows = sqlmock.NewRows([]string{
|
||||
"id",
|
||||
"name",
|
||||
"nickname",
|
||||
"email",
|
||||
"is_disabled",
|
||||
"is_system",
|
||||
}).AddRow(
|
||||
10,
|
||||
"John Doe",
|
||||
"Jonny",
|
||||
"jon@example.com",
|
||||
false,
|
||||
false,
|
||||
@ -104,7 +100,6 @@ func TestExampleTestSuite(t *testing.T) {
|
||||
func assertModel(t *testing.T, m Model) {
|
||||
assert.Equal(t, uint(10), m.ID)
|
||||
assert.Equal(t, "John Doe", m.Name)
|
||||
assert.Equal(t, "Jonny", m.Nickname)
|
||||
assert.Equal(t, "jon@example.com", m.Email)
|
||||
assert.Equal(t, false, m.IsDisabled)
|
||||
assert.Equal(t, false, m.IsSystem)
|
||||
@ -182,7 +177,7 @@ func (s *testsuite) TestSave() {
|
||||
WillReturnRows(s.singleRow)
|
||||
|
||||
s.mock.ExpectBegin()
|
||||
s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","nickname","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`)).
|
||||
s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING "id"`)).
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
@ -199,7 +194,6 @@ func (s *testsuite) TestSave() {
|
||||
// New model, as system
|
||||
m := Model{
|
||||
Name: "John Doe",
|
||||
Nickname: "Jonny",
|
||||
Email: "JON@example.com", // mixed case on purpose
|
||||
IsSystem: true,
|
||||
}
|
||||
|
@ -2,8 +2,10 @@ package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"npm/internal/database"
|
||||
"npm/internal/entity"
|
||||
"npm/internal/entity/auth"
|
||||
"npm/internal/logger"
|
||||
"npm/internal/model"
|
||||
)
|
||||
@ -104,3 +106,14 @@ func GetCapabilities(userID uint) ([]string, error) {
|
||||
}
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
// CreateFromLDAPUser will create a user from an LDAP user object
|
||||
func CreateFromLDAPUser(ldapUser *auth.LDAPUser) (Model, error) {
|
||||
user := Model{
|
||||
Email: ldapUser.Email,
|
||||
Name: ldapUser.Name,
|
||||
}
|
||||
err := user.Save()
|
||||
user.generateGravatar()
|
||||
return user, err
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import (
|
||||
type Model struct {
|
||||
model.ModelBase
|
||||
Name string `json:"name" gorm:"column:name" filter:"name,string"`
|
||||
Nickname string `json:"nickname" gorm:"column:nickname" filter:"nickname,string"`
|
||||
Email string `json:"email" gorm:"column:email" filter:"email,email"`
|
||||
IsDisabled bool `json:"is_disabled" gorm:"column:is_disabled" filter:"is_disabled,boolean"`
|
||||
IsSystem bool `json:"is_system,omitempty" gorm:"column:is_system" filter:"is_system,boolean"`
|
||||
|
Reference in New Issue
Block a user