diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index e0a9dd2f9..83f85aa8f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -20,6 +20,7 @@ import ( "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" "github.com/portainer/portainer/libcompose" + "github.com/portainer/portainer/oauth" "log" ) @@ -100,6 +101,10 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } +func initOAuthService() portainer.OAuthService { + return &oauth.Service{} +} + func initGitService() portainer.GitService { return &git.Service{} } @@ -260,6 +265,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL portainer.LDAPGroupSearchSettings{}, }, }, + OAuthSettings: portainer.OAuthSettings{}, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, EnableHostManagementFeatures: false, @@ -520,6 +526,8 @@ func main() { ldapService := initLDAPService() + oauthService := initOAuthService() + gitService := initGitService() cryptoService := initCryptoService() @@ -665,6 +673,7 @@ func main() { JWTService: jwtService, FileService: fileService, LDAPService: ldapService, + OAuthService: oauthService, GitService: gitService, SignatureService: digitalSignatureService, JobScheduler: jobScheduler, diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index d5562edd3..163023d0e 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -17,6 +17,10 @@ type authenticatePayload struct { Password string } +type oauthPayload struct { + Code string +} + type authenticateResponse struct { JWT string `json:"jwt"` } @@ -31,6 +35,13 @@ func (payload *authenticatePayload) Validate(r *http.Request) error { return nil } +func (payload *oauthPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Code) { + return portainer.Error("Invalid OAuth authorization code") + } + return nil +} + func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { if handler.authDisabled { return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} @@ -82,6 +93,58 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer. return handler.writeToken(w, user) } +func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload oauthPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod != 3 { + return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not being used", err} + } + + token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid access token", portainer.ErrUnauthorized} + } + + username, err := handler.OAuthService.GetUsername(token, &settings.OAuthSettings) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} + } + + u, err := handler.UserService.UserByUsername(username) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} + } + + if u == nil { + user := &portainer.User{ + Username: username, + Role: portainer.StandardUserRole, + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return handler.writeToken(w, user) + } + + return handler.writeToken(w, u) +} + func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError { err := handler.CryptoService.CompareHashAndData(user.Password, password) if err != nil { diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 1f0769e08..073da3a69 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -25,6 +25,7 @@ type Handler struct { CryptoService portainer.CryptoService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService SettingsService portainer.SettingsService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService @@ -38,6 +39,8 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi } h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + h.Handle("/oauth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticateOAuth)))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 9cb3059df..f818edf4c 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -60,6 +60,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/oauth"): + http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 0acbd2ca6..ee277d676 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -11,6 +11,7 @@ import ( func hideFields(settings *portainer.Settings) { settings.LDAPSettings.Password = "" + settings.OAuthSettings.ClientSecret = "" } // Handler is the HTTP handler used to handle settings operations. @@ -18,6 +19,7 @@ type Handler struct { *mux.Router SettingsService portainer.SettingsService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService FileService portainer.FileService JobScheduler portainer.JobScheduler ScheduleService portainer.ScheduleService diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 9744b319a..a803c22a5 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -15,6 +15,10 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` + AuthorizationURI string `json:"AuthorizationURI"` + ClientID string `json:"ClientID"` + RedirectURI string `json:"RedirectURI"` + Scopes string `json:"Scopes"` } // GET request on /api/settings/public @@ -31,6 +35,10 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, + AuthorizationURI: settings.OAuthSettings.AuthorizationURI, + ClientID: settings.OAuthSettings.ClientID, + RedirectURI: settings.OAuthSettings.RedirectURI, + Scopes: settings.OAuthSettings.Scopes, } if settings.TemplatesURL != "" { diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 5b4d33ae3..57c6d1d0a 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -16,6 +16,7 @@ type settingsUpdatePayload struct { BlackListedLabels []portainer.Pair AuthenticationMethod *int LDAPSettings *portainer.LDAPSettings + OAuthSettings *portainer.OAuthSettings AllowBindMountsForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool EnableHostManagementFeatures *bool @@ -24,7 +25,7 @@ type settingsUpdatePayload struct { } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 { + if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") } if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { @@ -69,6 +70,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.LDAPSettings = *payload.LDAPSettings } + if payload.OAuthSettings != nil { + settings.OAuthSettings = *payload.OAuthSettings + } + if payload.AllowBindMountsForRegularUsers != nil { settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers } diff --git a/api/http/server.go b/api/http/server.go index 1220d94d1..cf3c9ef2b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -55,6 +55,7 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService ExtensionService portainer.ExtensionService RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService @@ -104,6 +105,7 @@ func (server *Server) Start() error { authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService + authHandler.OAuthService = server.OAuthService authHandler.SettingsService = server.SettingsService authHandler.TeamService = server.TeamService authHandler.TeamMembershipService = server.TeamMembershipService @@ -155,6 +157,7 @@ func (server *Server) Start() error { var settingsHandler = settings.NewHandler(requestBouncer) settingsHandler.SettingsService = server.SettingsService settingsHandler.LDAPService = server.LDAPService + settingsHandler.OAuthService = server.OAuthService settingsHandler.FileService = server.FileService settingsHandler.JobScheduler = server.JobScheduler settingsHandler.ScheduleService = server.ScheduleService diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go new file mode 100644 index 000000000..bc175d46c --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,168 @@ +package oauth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/portainer/portainer" + "golang.org/x/oauth2" +) + +const ( + // ErrInvalidCode defines an error raised when the user authorization code is invalid + ErrInvalidCode = portainer.Error("Authorization code is invalid") +) + +// Service represents a service used to authenticate users against an authorization server +type Service struct{} + +// GetAccessToken takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint +func (*Service) GetAccessToken(code string, settings *portainer.OAuthSettings) (string, error) { + v := url.Values{} + v.Set("client_id", settings.ClientID) + v.Set("client_secret", settings.ClientSecret) + v.Set("redirect_uri", settings.RedirectURI) + v.Set("code", code) + v.Set("grant_type", "authorization_code") + + req, err := http.NewRequest("POST", settings.AccessTokenURI, strings.NewReader(v.Encode())) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r, err := client.Do(req) + if err != nil { + return "", err + } + + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + + if r.StatusCode != http.StatusOK { + log.Printf("[Error] - request returned with bad status code %v, body is %v", r.StatusCode, string(body)) + type ErrorMessage struct { + Message string + Type string + Code int + } + type ErrorResponse struct { + Error ErrorMessage + } + + var response ErrorResponse + if err = json.Unmarshal(body, &response); err != nil { + // report also error + log.Printf("[Error] - Failed parsing error body: %v", err) + return "", errors.New("oauth2: cannot fetch token") + } + + return "", errors.New(response.Error.Message) + } + + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + + token := values.Get("access_token") + log.Printf("[DEBUG] - returned body %v", values) + + if token == "" { + log.Printf("[DEBUG] - access token returned empty - %v", values) + return "", errors.New("oauth2: cannot fetch token") + } + + return token, nil + } + + type tokenJSON struct { + AccessToken string `json:"access_token"` + } + + var tj tokenJSON + if err = json.Unmarshal(body, &tj); err != nil { + return "", err + } + + token := tj.AccessToken + + if token == "" { + log.Printf("[DEBUG] - access token returned empty - %v with status code", string(body), r.StatusCode) + return "", errors.New("oauth2: cannot fetch token") + } + return token, nil +} + +// GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server. +func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (string, error) { + req, err := http.NewRequest("GET", settings.ResourceURI, nil) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + + content, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + + username := values.Get(settings.UserIdentifier) + return username, nil + } + + var datamap map[string]interface{} + if err = json.Unmarshal(body, &datamap); err != nil { + return "", err + } + + username, ok := datamap[settings.UserIdentifier].(string) + if ok && username != "" { + return username, nil + } + + if !ok { + username, ok := datamap[settings.UserIdentifier].(float64) + if ok { + return fmt.Sprint(int(username)), nil + } + } + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } +} diff --git a/api/portainer.go b/api/portainer.go index c8062ed7a..fe099769e 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -56,6 +56,19 @@ type ( AutoCreateUsers bool `json:"AutoCreateUsers"` } + // OAuthSettings represents the settings used to authorize with an authorization server + OAuthSettings struct { + ClientID string `json:"ClientID"` + ClientSecret string `json:"ClientSecret,omitempty"` + AccessTokenURI string `json:"AccessTokenURI"` + AuthorizationURI string `json:"AuthorizationURI"` + ResourceURI string `json:"ResourceURI"` + RedirectURI string `json:"RedirectURI"` + UserIdentifier string `json:"UserIdentifier"` + Scopes string `json:"Scopes"` + OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` + } + // TLSConfiguration represents a TLS configuration TLSConfiguration struct { TLS bool `json:"TLS"` @@ -85,6 +98,7 @@ type ( BlackListedLabels []Pair `json:"BlackListedLabels"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` LDAPSettings LDAPSettings `json:"LDAPSettings"` + OAuthSettings OAuthSettings `json:"OAuthSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` SnapshotInterval string `json:"SnapshotInterval"` @@ -748,6 +762,12 @@ type ( GetUserGroups(username string, settings *LDAPSettings) ([]string, error) } + // OAuthService represents a service used to authenticate users against an authorization server + OAuthService interface { + GetAccessToken(code string, settings *OAuthSettings) (string, error) + GetUsername(token string, settings *OAuthSettings) (string, error) + } + // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) @@ -833,6 +853,8 @@ const ( AuthenticationInternal // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server) AuthenticationLDAP + //AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server) + AuthenticationOAuth ) const ( diff --git a/app/constants.js b/app/constants.js index 464089158..f1b941de6 100644 --- a/app/constants.js +++ b/app/constants.js @@ -4,6 +4,7 @@ angular.module('portainer') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') +.constant('API_ENDPOINT_OAUTH', 'api/oauth') .constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') diff --git a/app/portainer/components/datatables/users-datatable/usersDatatable.html b/app/portainer/components/datatables/users-datatable/usersDatatable.html index 11692974d..e99fd16f5 100644 --- a/app/portainer/components/datatables/users-datatable/usersDatatable.html +++ b/app/portainer/components/datatables/users-datatable/usersDatatable.html @@ -65,8 +65,9 @@
LDAP authentication
+