diff --git a/internal/api/rest.go b/internal/api/rest.go index c199bc2..501cf3b 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -20,11 +20,13 @@ import ( "time" "github.com/ClusterCockpit/cc-backend/internal/auth" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/importer" "github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/repository" + "github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -75,6 +77,13 @@ func (api *RestApi) MountRoutes(r *mux.Router) { r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) + // r.HandleFunc("/secured/addProject/{id}/{project}", api.secureUpdateUser).Methods(http.MethodPost) + // r.HandleFunc("/secured/addRole/{id}/{role}", api.secureUpdateUser).Methods(http.MethodPost) + + if api.MachineStateDir != "" { + r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) + r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) + } if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) @@ -85,11 +94,6 @@ func (api *RestApi) MountRoutes(r *mux.Router) { r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) } - - if api.MachineStateDir != "" { - r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) - r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) - } } // StartJobApiResponse model @@ -103,6 +107,11 @@ type DeleteJobApiResponse struct { Message string `json:"msg"` } +// UpdateUserApiResponse model +type UpdateUserApiResponse struct { + Message string `json:"msg"` +} + // StopJobApiRequest model type StopJobApiRequest struct { // Stop Time of job as epoch @@ -172,6 +181,36 @@ func decode(r io.Reader, val interface{}) error { return dec.Decode(val) } +func securedCheck(r *http.Request) error { + user := auth.GetUser(r.Context()) + if user == nil { + return fmt.Errorf("no user in context") + } + + if user.AuthType == auth.AuthToken { + // If nothing declared in config: deny all request to this endpoint + if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { + return fmt.Errorf("missing configuration key ApiAllowedIPs") + } + + // extract IP address + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + + // check if IP is allowed + if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { + return fmt.Errorf("unknown ip: %v", IPAddress) + } + } + + return nil +} + // getJobs godoc // @summary Lists all jobs // @tags query @@ -892,6 +931,11 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + rw.Header().Set("Content-Type", "text/plain") username := r.FormValue("username") me := auth.GetUser(r.Context()) @@ -920,6 +964,11 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + rw.Header().Set("Content-Type", "text/plain") me := auth.GetUser(r.Context()) if !me.HasRole(auth.RoleAdmin) { @@ -927,17 +976,22 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { return } - username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") + username, password, role, name, email, project := r.FormValue("username"), + r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), + r.FormValue("email"), r.FormValue("project") + if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) return } if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { - http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) + http.Error(rw, "only managers require a project (can be changed later)", + http.StatusBadRequest) return } else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { - http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) + http.Error(rw, "managers require a project to manage (can be changed later)", + http.StatusBadRequest) return } @@ -956,6 +1010,11 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) return @@ -971,6 +1030,11 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) return @@ -986,6 +1050,11 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + user := auth.GetUser(r.Context()) if !user.HasRole(auth.RoleAdmin) { http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) @@ -1002,6 +1071,11 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) return @@ -1043,6 +1117,71 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { } } +// func (api *RestApi) secureUpdateUser(rw http.ResponseWriter, r *http.Request) { +// if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { +// handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) +// return +// } +// +// // IP CHECK HERE (WIP) +// // Probably better as private routine +// IPAddress := r.Header.Get("X-Real-Ip") +// if IPAddress == "" { +// IPAddress = r.Header.Get("X-Forwarded-For") +// } +// if IPAddress == "" { +// IPAddress = r.RemoteAddr +// } +// +// // Also This +// ipOk := false +// for _, a := range config.Keys.ApiAllowedAddrs { +// if a == IPAddress { +// ipOk = true +// } +// } +// +// if IPAddress == "" || ipOk == false { +// handleError(fmt.Errorf("unknown ip: %v", IPAddress), http.StatusForbidden, rw) +// return +// } +// // IP CHECK END +// +// // Get Values +// id := mux.Vars(r)["id"] +// newproj := mux.Vars(r)["project"] +// newrole := mux.Vars(r)["role"] +// +// // TODO: Handle anything but roles... +// if newrole != "" { +// if err := api.Authentication.AddRole(r.Context(), id, newrole); err != nil { +// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) +// return +// } +// +// rw.Header().Add("Content-Type", "application/json") +// rw.WriteHeader(http.StatusOK) +// json.NewEncoder(rw).Encode(UpdateUserApiResponse{ +// Message: fmt.Sprintf("Successfully added role %s to %s", newrole, id), +// }) +// +// } else if newproj != "" { +// if err := api.Authentication.AddProject(r.Context(), id, newproj); err != nil { +// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) +// return +// } +// +// rw.Header().Add("Content-Type", "application/json") +// rw.WriteHeader(http.StatusOK) +// json.NewEncoder(rw).Encode(UpdateUserApiResponse{ +// Message: fmt.Sprintf("Successfully added project %s to %s", newproj, id), +// }) +// +// } else { +// handleError(errors.New("Not Add [role|project]?"), http.StatusBadRequest, rw) +// } +// } + func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") key, value := r.FormValue("key"), r.FormValue("value") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8149bc1..500ef1a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -158,12 +158,13 @@ func Init(db *sqlx.DB, } if config, ok := configs["ldap"]; ok { - auth.LdapAuth = &LdapAuthenticator{} - if err := auth.LdapAuth.Init(auth, config); err != nil { - log.Error("Error while initializing authentication -> ldapAuth init failed") - return nil, err + ldapAuth := &LdapAuthenticator{} + if err := ldapAuth.Init(auth, config); err != nil { + log.Warn("Error while initializing authentication -> ldapAuth init failed") + } else { + auth.LdapAuth = ldapAuth + auth.authenticators = append(auth.authenticators, auth.LdapAuth) } - auth.authenticators = append(auth.authenticators, auth.LdapAuth) } jwtSessionAuth := &JWTSessionAuthenticator{} @@ -174,7 +175,7 @@ func Init(db *sqlx.DB, } jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} - if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + if err := jwtCookieSessionAuth.Init(auth, configs["jwt"]); err != nil { log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") } else { auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 17b5c0c..9feebc1 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -59,6 +59,8 @@ func (la *LdapAuthenticator) Init( log.Print("sync done") } }() + } else { + return fmt.Errorf("missing LDAP configuration") } return nil @@ -73,7 +75,7 @@ func (la *LdapAuthenticator) CanLogin( if user != nil && user.AuthSource == AuthViaLDAP { return true } else { - if la.config.SyncUserOnLogin { + if la.config != nil && la.config.SyncUserOnLogin { l, err := la.getLdapConnection(true) if err != nil { log.Error("LDAP connection error") diff --git a/internal/util/array.go b/internal/util/array.go new file mode 100644 index 0000000..bc7ed04 --- /dev/null +++ b/internal/util/array.go @@ -0,0 +1,14 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package util + +func Contains[T comparable](items []T, item T) bool { + for _, v := range items { + if v == item { + return true + } + } + return false +} diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 2a4047c..95cc641 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -70,6 +70,9 @@ type ProgramConfig struct { // Address where the http (or https) server will listen on (for example: 'localhost:80'). Addr string `json:"addr"` + // Addresses from which the /api/secured/* API endpoints can be reached + ApiAllowedIPs []string `json:"apiAllowedIPs"` + // Drop root permissions once .env was read and the port was taken. User string `json:"user"` Group string `json:"group"`