diff --git a/cmd/wg-portal/common/config.go b/cmd/wg-portal/common/config.go index a50ccaa..3929549 100644 --- a/cmd/wg-portal/common/config.go +++ b/cmd/wg-portal/common/config.go @@ -3,6 +3,8 @@ import ( "os" + "github.com/pkg/errors" + "github.com/go-ldap/ldap/v3" "github.com/h44z/wg-portal/internal/persistence" "github.com/h44z/wg-portal/internal/portal" @@ -42,10 +44,14 @@ AdminGroupDN string `yaml:"admin_group"` // Members of this group receive admin rights in WG-Portal adminGroupDN *ldap.DN `yaml:"-"` - Synchronize bool `yaml:"synchronize"` - DeleteMissing bool `yaml:"delete_missing"` // if DeleteMissing is false, missing users will be deactivated - SyncFilter string `yaml:"sync_filter"` - RegistrationEnabled bool `yaml:"registration_enabled"` + Synchronize bool `yaml:"synchronize"` + + // If DeleteMissing is false, missing users will be deactivated + DeleteMissing bool `yaml:"delete_missing"` + SyncFilter string `yaml:"sync_filter"` + + // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database. + RegistrationEnabled bool `yaml:"registration_enabled"` } type OpenIDConnectProvider struct { @@ -63,10 +69,13 @@ // ClientSecret is the application's secret. ClientSecret string `yaml:"client_secret"` + // ExtraScopes specifies optional requested permissions. ExtraScopes []string `yaml:"extra_scopes"` + // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields FieldMap OauthFields `yaml:"field_map"` + // If RegistrationEnabled is set to true, missing users will be created in the database RegistrationEnabled bool `yaml:"registration_enabled"` } @@ -96,7 +105,7 @@ // Scope specifies optional requested permissions. Scopes []string `yaml:"scopes"` - // Fieldmap contains + // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields FieldMap OauthFields `yaml:"field_map"` // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database. @@ -105,29 +114,29 @@ type Config struct { Core struct { - GinDebug bool `yaml:"ginDebug"` - LogLevel string `yaml:"logLevel"` + GinDebug bool `yaml:"gin_debug"` + LogLevel string `yaml:"log_level"` - ListeningAddress string `yaml:"listeningAddress"` - SessionSecret string `yaml:"sessionSecret"` + ListeningAddress string `yaml:"listening_address"` + SessionSecret string `yaml:"session_secret"` - ExternalUrl string `yaml:"externalUrl"` + ExternalUrl string `yaml:"external_url"` Title string `yaml:"title"` CompanyName string `yaml:"company"` - // TODO: check... - AdminUser string `yaml:"adminUser"` // must be an email address - AdminPassword string `yaml:"adminPass"` + // AdminUser defines the default administrator account that will be created + AdminUser string `yaml:"admin_user"` // must be an email address + AdminPassword string `yaml:"admin_password"` - EditableKeys bool `yaml:"editableKeys"` - CreateDefaultPeer bool `yaml:"createDefaultPeer"` - SelfProvisioningAllowed bool `yaml:"selfProvisioning"` - LdapEnabled bool `yaml:"ldapEnabled"` - LogoUrl string `yaml:"logoUrl"` + EditableKeys bool `yaml:"editable_keys"` + CreateDefaultPeer bool `yaml:"create_default_peer"` + SelfProvisioningAllowed bool `yaml:"self_provisioning_allowed"` + LdapEnabled bool `yaml:"ldap_enabled"` + LogoUrl string `yaml:"logo_url"` } `yaml:"core"` Auth struct { - OpenIDConnect []OpenIDConnectProvider `yaml:"openIdCconnect"` + OpenIDConnect []OpenIDConnectProvider `yaml:"oidc"` OAuth []OAuthProvider `yaml:"oauth"` Ldap []LdapProvider `yaml:"ldap"` } `yaml:"auth"` @@ -139,14 +148,14 @@ func LoadConfigFile(cfg interface{}, filename string) error { f, err := os.Open(filename) if err != nil { - return err + return errors.WithMessage(err, "failed to open file") } defer f.Close() decoder := yaml.NewDecoder(f) err = decoder.Decode(cfg) if err != nil { - return err + return errors.WithMessage(err, "failed to decode config file") } return nil diff --git a/cmd/wg-portal/common/ldap.go b/cmd/wg-portal/common/ldap.go index ad85f2d..a3c6494 100644 --- a/cmd/wg-portal/common/ldap.go +++ b/cmd/wg-portal/common/ldap.go @@ -241,6 +241,7 @@ } } +// userIsInAdminGroup checks if the groupData array contains the admin group DN func userIsInAdminGroup(groupData [][]byte, adminGroupDN *ldap.DN) (bool, error) { for _, group := range groupData { dn, err := ldap.ParseDN(string(group)) @@ -291,16 +292,3 @@ return defaultMap } - -// uniqueStringSlice removes duplicates in the given string slice -func uniqueStringSlice(slice []string) []string { - keys := make(map[string]struct{}) - uniqueSlice := make([]string, 0, len(slice)) - for _, entry := range slice { - if _, exists := keys[entry]; !exists { - keys[entry] = struct{}{} - uniqueSlice = append(uniqueSlice, entry) - } - } - return uniqueSlice -} diff --git a/cmd/wg-portal/common/ldap_test.go b/cmd/wg-portal/common/ldap_test.go new file mode 100644 index 0000000..213d575 --- /dev/null +++ b/cmd/wg-portal/common/ldap_test.go @@ -0,0 +1,95 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-ldap/ldap/v3" +) + +func Test_getLdapFieldMapping(t *testing.T) { + defaultFields := LdapFields{ + BaseFields: BaseFields{ + UserIdentifier: "mail", + Email: "mail", + Firstname: "givenName", + Lastname: "sn", + Phone: "telephoneNumber", + Department: "department", + }, + GroupMembership: "memberOf", + } + + got := getLdapFieldMapping(LdapFields{}) + assert.Equal(t, defaultFields, got) + + customFields := LdapFields{ + BaseFields: BaseFields{ + UserIdentifier: "field_uid", + Email: "field_email", + Firstname: "field_fn", + Lastname: "field_ln", + Phone: "field_phone", + Department: "field_dep", + }, + GroupMembership: "field_member", + } + + got = getLdapFieldMapping(customFields) + assert.Equal(t, customFields, got) +} + +func Test_userIsInAdminGroup(t *testing.T) { + adminDN, _ := ldap.ParseDN("CN=admin,OU=groups,DC=TEST,DC=COM") + + tests := []struct { + name string + groupData [][]byte + want bool + wantErr bool + }{ + { + name: "NoGroups", + groupData: nil, + want: false, + wantErr: false, + }, + { + name: "WrongGroups", + groupData: [][]byte{[]byte("cn=wrong,dc=group"), []byte("CN=wrong2,OU=groups,DC=TEST,DC=COM")}, + want: false, + wantErr: false, + }, + { + name: "CorrectGroups", + groupData: [][]byte{[]byte("CN=admin,OU=groups,DC=TEST,DC=COM")}, + want: true, + wantErr: false, + }, + { + name: "CorrectGroupsCase", + groupData: [][]byte{[]byte("cn=admin,OU=groups,dc=TEST,DC=COM")}, + want: true, + wantErr: false, + }, + { + name: "WrongDN", + groupData: [][]byte{[]byte("i_am_invalid")}, + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := userIsInAdminGroup(tt.groupData, adminDN) + if (err != nil) != tt.wantErr { + t.Errorf("userIsInAdminGroup() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("userIsInAdminGroup() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/wg-portal/common/oauth.go b/cmd/wg-portal/common/oauth.go index 9b6912b..57a39bc 100644 --- a/cmd/wg-portal/common/oauth.go +++ b/cmd/wg-portal/common/oauth.go @@ -3,7 +3,6 @@ import ( "context" "encoding/json" - "fmt" "io/ioutil" "net/http" "strconv" @@ -258,16 +257,3 @@ return defaultMap } - -func mapDefaultString(m map[string]interface{}, key string, dflt string) string { - if tmp, ok := m[key]; !ok { - return dflt - } else { - switch v := tmp.(type) { - case string: - return v - default: - return fmt.Sprintf("%v", v) - } - } -} diff --git a/cmd/wg-portal/common/oauth_test.go b/cmd/wg-portal/common/oauth_test.go new file mode 100644 index 0000000..f6e6a3a --- /dev/null +++ b/cmd/wg-portal/common/oauth_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_getOauthFieldMapping(t *testing.T) { + defaultFields := OauthFields{ + BaseFields: BaseFields{ + UserIdentifier: "sub", + Email: "email", + Firstname: "given_name", + Lastname: "family_name", + Phone: "phone", + Department: "department", + }, + IsAdmin: "admin_flag", + } + + got := getOauthFieldMapping(OauthFields{}) + assert.Equal(t, defaultFields, got) + + customFields := OauthFields{ + BaseFields: BaseFields{ + UserIdentifier: "field_uid", + Email: "field_email", + Firstname: "field_fn", + Lastname: "field_ln", + Phone: "field_phone", + Department: "field_dep", + }, + IsAdmin: "field_admin", + } + + got = getOauthFieldMapping(customFields) + assert.Equal(t, customFields, got) +} diff --git a/cmd/wg-portal/common/utils.go b/cmd/wg-portal/common/utils.go new file mode 100644 index 0000000..9ce6737 --- /dev/null +++ b/cmd/wg-portal/common/utils.go @@ -0,0 +1,35 @@ +package common + +import "fmt" + +// mapDefaultString returns the string value for the given key or a default value +func mapDefaultString(m map[string]interface{}, key string, dflt string) string { + if m == nil { + return dflt + } + if tmp, ok := m[key]; !ok { + return dflt + } else { + switch v := tmp.(type) { + case string: + return v + case nil: + return dflt + default: + return fmt.Sprintf("%v", v) + } + } +} + +// uniqueStringSlice removes duplicates in the given string slice +func uniqueStringSlice(slice []string) []string { + keys := make(map[string]struct{}) + uniqueSlice := make([]string, 0, len(slice)) + for _, entry := range slice { + if _, exists := keys[entry]; !exists { + keys[entry] = struct{}{} + uniqueSlice = append(uniqueSlice, entry) + } + } + return uniqueSlice +} diff --git a/cmd/wg-portal/common/utils_test.go b/cmd/wg-portal/common/utils_test.go new file mode 100644 index 0000000..e254288 --- /dev/null +++ b/cmd/wg-portal/common/utils_test.go @@ -0,0 +1,108 @@ +package common + +import ( + "reflect" + "testing" +) + +func Test_mapDefaultString(t *testing.T) { + type args struct { + m map[string]interface{} + key string + defaultValue string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "match", + args: args{ + m: map[string]interface{}{"hello": "world"}, + key: "hello", + defaultValue: "", + }, + want: "world", + }, { + name: "no_match", + args: args{ + m: map[string]interface{}{"hello": "world"}, + key: "hi", + defaultValue: "", + }, + want: "", + }, { + name: "nil_value", + args: args{ + m: map[string]interface{}{"hello": nil}, + key: "hello", + defaultValue: "", + }, + want: "", + }, { + name: "default_nil_value", + args: args{ + m: map[string]interface{}{"hello": nil}, + key: "hello", + defaultValue: "world", + }, + want: "world", + }, { + name: "nil_map", + args: args{ + m: nil, + key: "hi", + defaultValue: "world", + }, + want: "world", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mapDefaultString(tt.args.m, tt.args.key, tt.args.defaultValue); got != tt.want { + t.Errorf("mapDefaultString() = %v, want %v", got, tt.want) + } + }) + } + +} + +func Test_uniqueStringSlice(t *testing.T) { + type args struct { + slice []string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "Empty", + args: args{}, + want: []string{}, + }, + { + name: "Single", + args: args{slice: []string{"1"}}, + want: []string{"1"}, + }, + { + name: "Normal", + args: args{slice: []string{"1", "2", "3"}}, + want: []string{"1", "2", "3"}, + }, + { + name: "Duplicate", + args: args{slice: []string{"1", "2", "2"}}, + want: []string{"1", "2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := uniqueStringSlice(tt.args.slice); !reflect.DeepEqual(got, tt.want) { + t.Errorf("UniqueStringSlice() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/wg-portal/ui/pages_core.go b/cmd/wg-portal/ui/pages_core.go index 9cd5e0b..fca4e1e 100644 --- a/cmd/wg-portal/ui/pages_core.go +++ b/cmd/wg-portal/ui/pages_core.go @@ -135,8 +135,8 @@ h.session.SetData(c, authSession) nextUrl := "/" - if currentSession.DeeplLink != "" { - nextUrl = currentSession.DeeplLink + if currentSession.DeepLink != "" { + nextUrl = currentSession.DeepLink } c.Redirect(http.StatusSeeOther, nextUrl) @@ -233,8 +233,8 @@ h.session.SetData(c, sessionData) nextUrl := "/" - if currentSession.DeeplLink != "" { - nextUrl = currentSession.DeeplLink + if currentSession.DeepLink != "" { + nextUrl = currentSession.DeepLink } c.Redirect(http.StatusSeeOther, nextUrl) @@ -265,7 +265,7 @@ Identifier: userInfo.Identifier, Email: userInfo.Email, Source: persistence.UserSourceLdap, - IsAdmin: false, + IsAdmin: userInfo.IsAdmin, Firstname: userInfo.Firstname, Lastname: userInfo.Lastname, Phone: userInfo.Phone, diff --git a/cmd/wg-portal/ui/session.go b/cmd/wg-portal/ui/session.go index ac1966c..a157621 100644 --- a/cmd/wg-portal/ui/session.go +++ b/cmd/wg-portal/ui/session.go @@ -15,7 +15,7 @@ } type SessionData struct { - DeeplLink string // deep link, used to redirect after a successful login + DeepLink string // deep link, used to redirect after a successful login OauthState string // oauth state OidcNonce string // oidc id token nonce @@ -27,15 +27,20 @@ Lastname string Email string + // currently selected interface InterfaceIdentifier persistence.InterfaceIdentifier + // current table sorting SortedBy map[string]string SortDirection map[string]string Search map[string]string + // alert that is printed on top of the page AlertData string AlertType string - FormData interface{} + + // currently filled form data + FormData interface{} } type FlashData struct { diff --git a/internal/persistence/database.go b/internal/persistence/database.go index daa34f5..8f20cbc 100644 --- a/internal/persistence/database.go +++ b/internal/persistence/database.go @@ -25,8 +25,8 @@ type DatabaseFilterCondition func(tx *gorm.DB) *gorm.DB type DatabaseConfig struct { - Type SupportedDatabase `yaml:"type" envconfig:"DB_TYPE"` - DSN string `yaml:"dsn" envconfig:"DB_DSN"` // On SQLite: the database file-path, otherwise the dsn (see: https://gorm.io/docs/connecting_to_the_database.html) + Type SupportedDatabase `yaml:"type"` + DSN string `yaml:"dsn"` // On SQLite: the database file-path, otherwise the dsn (see: https://gorm.io/docs/connecting_to_the_database.html) } type Database struct { diff --git a/internal/portal/mail.go b/internal/portal/mail.go index 78c530b..e9d155b 100644 --- a/internal/portal/mail.go +++ b/internal/portal/mail.go @@ -17,13 +17,13 @@ ) type MailConfig struct { - Host string `yaml:"host" envconfig:"EMAIL_HOST"` - Port int `yaml:"port" envconfig:"EMAIL_PORT"` - Encryption MailEncryption `yaml:"encryption" envconfig:"EMAIL_ENCRYPTION"` - CertValidation bool `yaml:"certCheck" envconfig:"EMAIL_CERT_VALIDATION"` - Username string `yaml:"user" envconfig:"EMAIL_USERNAME"` - Password string `yaml:"pass" envconfig:"EMAIL_PASSWORD"` - AuthType MailAuthType `yaml:"auth" envconfig:"EMAIL_AUTHTYPE"` - MailFrom string `yaml:"mailFrom" envconfig:"MAIL_FROM"` - IncludeSensitiveData bool `yaml:"withSensitiveData" envconfig:"EMAIL_INCLUDE_SENSITIVE_DATA"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Encryption MailEncryption `yaml:"encryption"` + CertValidation bool `yaml:"cert_validation"` + Username string `yaml:"user"` + Password string `yaml:"pass"` + AuthType MailAuthType `yaml:"auth"` + MailFrom string `yaml:"mail_from"` + IncludeSensitiveData bool `yaml:"include_sensitive_data"` }