Skip to content

Commit

Permalink
Adds LDAP authentication to Fider
Browse files Browse the repository at this point in the history
  • Loading branch information
esgn committed Oct 15, 2021
1 parent b8c1c4d commit 58cb2e3
Show file tree
Hide file tree
Showing 58 changed files with 2,223 additions and 44 deletions.
150 changes: 150 additions & 0 deletions app/actions/ldap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package actions

import (
"context"
"strconv"
"strings"

"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/enum"
"github.com/getfider/fider/app/models/query"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/rand"
"github.com/getfider/fider/app/pkg/validate"
)

// IsInteger verifies if string is an integer
func IsInteger(s string) bool {
_, err := strconv.Atoi(s)
return err == nil
}

// CreateEditLdapConfig is used to create/edit LDAP configuration
type CreateEditLdapConfig struct {
ID int
Provider string `json:"provider"`
DisplayName string `json:"displayName"`
Status int `json:"status"`
Protocol int `json:"protocol"`
CertCheck bool `json:"certCheck"`
LdapHostname string `json:"ldapHostname"`
LdapPort string `json:"ldapPort"`
BindUsername string `json:"bindUsername"`
BindPassword string `json:"bindPassword"`
RootDN string `json:"rootDN"`
Scope int `json:"scope"`
UserSearchFilter string `json:"userSearchFilter"`
UsernameLdapAttribute string `json:"usernameLdapAttribute"`
NameLdapAttribute string `json:"nameLdapAttribute"`
MailLdapAttribute string `json:"mailLdapAttribute"`
}

func NewCreateEditLdapConfig() *CreateEditLdapConfig {
return &CreateEditLdapConfig{}
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *CreateEditLdapConfig) IsAuthorized(ctx context.Context, user *entity.User) bool {
return user != nil && user.IsAdministrator()
}

// Validate if current model is valid
func (action *CreateEditLdapConfig) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()

if action.Provider != "" {
getConfig := &query.GetCustomLdapConfigByProvider{Provider: action.Provider}
err := bus.Dispatch(ctx, getConfig)
if err != nil {
return validate.Error(err)
}
action.ID = getConfig.Result.ID
if action.BindPassword == "" {
action.BindPassword = getConfig.Result.BindPassword
}

} else {
action.Provider = "_" + strings.ToLower(rand.String(10))
}

if action.Status != enum.LdapConfigEnabled &&
action.Status != enum.LdapConfigDisabled {
result.AddFieldFailure("status", "Invalid status.")
}

if action.Protocol != enum.LDAP &&
action.Protocol != enum.LDAPTLS &&
action.Protocol != enum.LDAPS {
result.AddFieldFailure("protocol", "Invalid Protocol status.")
}

if action.Scope != enum.ScopeBaseObject &&
action.Scope != enum.ScopeSingleLevel &&
action.Scope != enum.ScopeWholeSubtree {
result.AddFieldFailure("scope", "Invalid scope status.")
}

if action.DisplayName == "" {
result.AddFieldFailure("displayName", "Display Name is required.")
} else if len(action.DisplayName) > 50 {
result.AddFieldFailure("displayName", "Display Name must have less than 50 characters.")
}

if action.LdapHostname == "" {
result.AddFieldFailure("ldapHostname", "LDAP Domain is required.")
} else if len(action.LdapHostname) > 300 {
result.AddFieldFailure("ldapHostname", "LDAP Domain must have less than 300 characters.")
}

if action.LdapPort == "" {
result.AddFieldFailure("ldapPort", "LDAP port is required.")
} else if len(action.LdapPort) > 10 {
result.AddFieldFailure("ldapPort", "LDAP port must be less than 10 digits.")
} else if !IsInteger(action.LdapPort) {
result.AddFieldFailure("ldapPort", "LDAP must be an integer")
}

if action.BindUsername == "" {
result.AddFieldFailure("bindUsername", "Bind username is required.")
} else if len(action.BindUsername) > 100 {
result.AddFieldFailure("bindUsername", "Bind username must have less than 100 characters.")
}

if action.BindPassword == "" {
result.AddFieldFailure("bindPassword", "Bind password is required.")
} else if len(action.BindPassword) > 100 {
result.AddFieldFailure("bindPassword", "Bind password must have less than 100 characters.")
}

if action.RootDN == "" {
result.AddFieldFailure("rootDN", "Root DN is required.")
} else if len(action.RootDN) > 250 {
result.AddFieldFailure("rootDN", "Root DN must have less than 250 characters.")
}

if action.UserSearchFilter == "" {
result.AddFieldFailure("userSearchFilter", "User Search Filter is required.")
} else if len(action.UserSearchFilter) > 500 {
result.AddFieldFailure("userSearchFilter", "User Search Filter must have less than 500 characters.")
}

if action.UsernameLdapAttribute == "" {
result.AddFieldFailure("usernameLdapAttribute", "Username LDAP attribute is required.")
} else if len(action.UsernameLdapAttribute) > 100 {
result.AddFieldFailure("usernameLdapAttribute", "Username LDAP attribute must have less than 100 characters.")
}

if action.NameLdapAttribute == "" {
result.AddFieldFailure("nameLdapAttribute", "Full Name LDAP attribute is required.")
} else if len(action.NameLdapAttribute) > 100 {
result.AddFieldFailure("nameLdapAttribute", "Full Name LDAP attribute must have less than 100 characters.")
}

if action.MailLdapAttribute == "" {
result.AddFieldFailure("mailLdapAttribute", "Mail LDAP attribute is required.")
} else if len(action.MailLdapAttribute) > 100 {
result.AddFieldFailure("mailLdapAttribute", "Mail LDAP attribute must have less than 100 characters.")
}

return result
}
121 changes: 121 additions & 0 deletions app/actions/ldap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package actions_test

import (
"context"
"testing"

"github.com/getfider/fider/app"
"github.com/getfider/fider/app/actions"
"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/enum"
. "github.com/getfider/fider/app/pkg/assert"
"github.com/getfider/fider/app/pkg/rand"
)

func TestCreateEditLdapConfig_Validate_InvalidInput(t *testing.T) {
RegisterT(t)

testCases := []struct {
expected []string
action *actions.CreateEditLdapConfig
}{
{
expected: []string{"status", "protocol", "scope", "displayName", "ldapHostname", "ldapPort", "bindUsername", "bindPassword", "rootDN", "userSearchFilter", "usernameLdapAttribute", "nameLdapAttribute", "mailLdapAttribute"},
action: &actions.CreateEditLdapConfig{},
},
{
expected: []string{"status", "protocol", "scope", "displayName", "ldapHostname", "ldapPort", "bindUsername", "bindPassword", "rootDN", "userSearchFilter", "usernameLdapAttribute", "nameLdapAttribute", "mailLdapAttribute"},
action: &actions.CreateEditLdapConfig{
ID: 0,
Provider: "",
DisplayName: rand.String(51),
Status: 0,
Protocol: 0,
LdapHostname: rand.String(301),
LdapPort: "12345678910",
BindUsername: rand.String(101),
BindPassword: rand.String(101),
RootDN: rand.String(251),
Scope: 0,
UserSearchFilter: rand.String(501),
UsernameLdapAttribute: rand.String(101),
NameLdapAttribute: rand.String(101),
MailLdapAttribute: rand.String(101),
},
},
{
expected: []string{"ldapPort"},
action: &actions.CreateEditLdapConfig{
ID: 0,
Provider: "",
DisplayName: "Test",
Status: enum.LdapConfigEnabled,
Protocol: enum.LDAP,
LdapHostname: "Hostname",
LdapPort: "Invalid",
BindUsername: "Bind Username",
BindPassword: "Bind Password",
RootDN: "Root DN",
Scope: enum.ScopeBaseObject,
UserSearchFilter: "User Search Filter",
UsernameLdapAttribute: "Username LDAP Attribute",
NameLdapAttribute: "Name LDAP Attribute",
MailLdapAttribute: "Mail LDAP Attribute",
},
},
}

ctx := context.WithValue(context.Background(), app.TenantCtxKey, &entity.Tenant{
IsEmailAuthAllowed: true,
})

for _, testCase := range testCases {
result := testCase.action.Validate(ctx, nil)
ExpectFailed(result, testCase.expected...)
}
}

func TestCreateEditLdapConfig_Validate_ValidInput(t *testing.T) {
RegisterT(t)

action := &actions.CreateEditLdapConfig{
ID: 0,
Provider: "",
DisplayName: "Test",
Status: enum.LdapConfigEnabled,
Protocol: enum.LDAP,
LdapHostname: "Hostname",
LdapPort: "1234",
BindUsername: "Bind Username",
BindPassword: "Bind Password",
RootDN: "Root DN",
Scope: enum.ScopeBaseObject,
UserSearchFilter: "User Search Filter",
UsernameLdapAttribute: "Username LDAP Attribute",
NameLdapAttribute: "Name LDAP Attribute",
MailLdapAttribute: "Mail LDAP Attribute",
}

ctx := context.WithValue(context.Background(), app.TenantCtxKey, &entity.Tenant{
IsEmailAuthAllowed: true,
})

result := action.Validate(ctx, nil)
ExpectSuccess(result)
}

func TestCreateEditLdapConfig_DefaultValues(t *testing.T) {
RegisterT(t)

action := &actions.CreateEditLdapConfig{}
Expect(action.ID).Equals(0)
}

func TestCreateEditLdapConfig_IsAuthorized(t *testing.T) {
RegisterT(t)

action := &actions.CreateEditLdapConfig{}
Expect(action.IsAuthorized(context.Background(), nil)).IsFalse()
Expect(action.IsAuthorized(context.Background(), &entity.User{})).IsFalse()
Expect(action.IsAuthorized(context.Background(), &entity.User{Role: enum.RoleAdministrator})).IsTrue()
}
44 changes: 43 additions & 1 deletion app/actions/signin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package actions

import (
"context"
"github.com/getfider/fider/app"

"github.com/getfider/fider/app"
"github.com/getfider/fider/app/models/cmd"
"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/enum"
"github.com/getfider/fider/app/models/query"
Expand Down Expand Up @@ -115,3 +116,44 @@ func (action *CompleteProfile) Validate(ctx context.Context, user *entity.User)

return result
}

// SignInWithLdap happens when user request to sign in with ldap
type SignInByLdap struct {
Username string `json:"username"`
Password string `json:"password"`
Provider string `json:"provider"`
}

func NewSignInByLdap() *SignInByLdap {
return &SignInByLdap{}
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *SignInByLdap) IsAuthorized(ctx context.Context, user *entity.User) bool {
return true
}

// Validate if current model is valid
func (action *SignInByLdap) Validate(ctx context.Context, user *entity.User) *validate.Result {

result := validate.Success()

if action.Username == "" {
result.AddFieldFailure("ldapUsername", propertyIsRequired(ctx, "username"))
return result
}

if action.Password == "" {
result.AddFieldFailure("ldapPassword", propertyIsRequired(ctx, "password"))
return result
}

// should this be a query instead of a command ?
verify := &cmd.VerifyLdapUser{Provider: action.Provider, Username: action.Username, Password: action.Password}

if err := bus.Dispatch(ctx, verify); err != nil {
result.AddFieldFailure("ldapPassword", propertyIsInvalid(ctx, "login"))
}

return result
}
14 changes: 11 additions & 3 deletions app/actions/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package actions

import (
"context"

"github.com/getfider/fider/app/models/query"
"github.com/getfider/fider/app/pkg/bus"

Expand Down Expand Up @@ -219,12 +220,19 @@ func (action *UpdateTenantEmailAuthAllowed) IsAuthorized(ctx context.Context, us
func (action *UpdateTenantEmailAuthAllowed) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()

activeProviders := &query.ListActiveOAuthProviders{}
if err := bus.Dispatch(ctx, activeProviders); err != nil {
activeOauthProviders := &query.ListActiveOAuthProviders{}
if err := bus.Dispatch(ctx, activeOauthProviders); err != nil {
return validate.Failed("Cannot retrieve OAuth providers")
}

if len(activeProviders.Result) == 0 {
// Adding LDAP
activeLdapProviders := &query.ListActiveLdapProviders{}
if err := bus.Dispatch(ctx, activeLdapProviders); err != nil {
return validate.Failed("Cannot retrieve Ldap providers")
}

// Adding LDAP
if len(activeOauthProviders.Result) == 0 && len(activeLdapProviders.Result) == 0 {
result.AddFieldFailure("isEmailAuthAllowed", "You cannot disable email authentication without any other provider enabled.")
}

Expand Down
4 changes: 4 additions & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func routes(r *web.Engine) *web.Engine {
r.Get("/invite/verify", handlers.VerifySignInKey(enum.EmailVerificationKindUserInvitation))
r.Post("/_api/signin/complete", handlers.CompleteSignInProfile())
r.Post("/_api/signin", handlers.SignInByEmail())
r.Post("/_api/ldap/signin", handlers.SignInByLdap())

//Block if it's a locked tenant with a non-administrator user
r.Use(middlewares.BlockLockedTenants())
Expand Down Expand Up @@ -158,6 +159,8 @@ func routes(r *web.Engine) *web.Engine {
ui.Get("/admin/tags", handlers.ManageTags())
ui.Get("/admin/authentication", handlers.ManageAuthentication())
ui.Get("/_api/admin/oauth/:provider", handlers.GetOAuthConfig())
ui.Get("/_api/admin/ldap/:provider", handlers.GetLdapConfig())
ui.Get("/_api/admin/ldap/:provider/test", handlers.TestLdapServer())

//From this step, only Administrators are allowed
ui.Use(middlewares.IsAuthorized(enum.RoleAdministrator))
Expand All @@ -180,6 +183,7 @@ func routes(r *web.Engine) *web.Engine {
ui.Post("/_api/admin/roles/:role/users", handlers.ChangeUserRole())
ui.Put("/_api/admin/users/:userID/block", handlers.BlockUser())
ui.Delete("/_api/admin/users/:userID/block", handlers.UnblockUser())
ui.Post("/_api/admin/ldap", handlers.SaveLdapConfig())

if env.IsBillingEnabled() {
ui.Get("/admin/billing", handlers.ManageBilling())
Expand Down
1 change: 1 addition & 0 deletions app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
_ "github.com/getfider/fider/app/services/email/mailgun"
_ "github.com/getfider/fider/app/services/email/smtp"
_ "github.com/getfider/fider/app/services/httpclient"
_ "github.com/getfider/fider/app/services/ldap"
_ "github.com/getfider/fider/app/services/log/console"
_ "github.com/getfider/fider/app/services/log/file"
_ "github.com/getfider/fider/app/services/log/sql"
Expand Down
Loading

0 comments on commit 58cb2e3

Please sign in to comment.